televault 0.1.0__py3-none-any.whl → 2.0.0__py3-none-any.whl
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.
- televault/__init__.py +1 -1
- televault/chunker.py +29 -27
- televault/cli.py +237 -90
- televault/compress.py +59 -23
- televault/config.py +16 -17
- televault/core.py +140 -203
- televault/crypto.py +26 -33
- televault/models.py +29 -30
- televault/telegram.py +136 -107
- televault/tui.py +632 -0
- televault-2.0.0.dist-info/METADATA +310 -0
- televault-2.0.0.dist-info/RECORD +14 -0
- {televault-0.1.0.dist-info → televault-2.0.0.dist-info}/entry_points.txt +1 -0
- televault-0.1.0.dist-info/METADATA +0 -242
- televault-0.1.0.dist-info/RECORD +0 -13
- {televault-0.1.0.dist-info → televault-2.0.0.dist-info}/WHEEL +0 -0
televault/core.py
CHANGED
|
@@ -1,19 +1,18 @@
|
|
|
1
1
|
"""Core TeleVault operations - upload, download, list."""
|
|
2
2
|
|
|
3
3
|
import asyncio
|
|
4
|
+
import hashlib
|
|
4
5
|
import os
|
|
5
|
-
import
|
|
6
|
-
from pathlib import Path
|
|
7
|
-
from typing import Optional, Callable
|
|
6
|
+
from collections.abc import Callable
|
|
8
7
|
from dataclasses import dataclass
|
|
9
|
-
import
|
|
8
|
+
from pathlib import Path
|
|
10
9
|
|
|
11
|
-
from .
|
|
12
|
-
from .models import FileMetadata, ChunkInfo, VaultIndex
|
|
13
|
-
from .telegram import TelegramVault, TelegramConfig
|
|
14
|
-
from .chunker import iter_chunks, hash_file, hash_data, ChunkWriter, DEFAULT_CHUNK_SIZE
|
|
15
|
-
from .crypto import encrypt_chunk, decrypt_chunk
|
|
10
|
+
from .chunker import ChunkWriter, hash_data, hash_file, iter_chunks
|
|
16
11
|
from .compress import compress_data, decompress_data, should_compress
|
|
12
|
+
from .config import Config
|
|
13
|
+
from .crypto import decrypt_chunk, encrypt_chunk
|
|
14
|
+
from .models import ChunkInfo, FileMetadata
|
|
15
|
+
from .telegram import TelegramConfig, TelegramVault
|
|
17
16
|
|
|
18
17
|
|
|
19
18
|
def generate_file_id(name: str, size: int) -> str:
|
|
@@ -25,13 +24,14 @@ def generate_file_id(name: str, size: int) -> str:
|
|
|
25
24
|
@dataclass
|
|
26
25
|
class UploadProgress:
|
|
27
26
|
"""Progress information for upload."""
|
|
27
|
+
|
|
28
28
|
file_name: str
|
|
29
29
|
total_size: int
|
|
30
30
|
uploaded_size: int
|
|
31
31
|
total_chunks: int
|
|
32
32
|
uploaded_chunks: int
|
|
33
33
|
current_chunk: int
|
|
34
|
-
|
|
34
|
+
|
|
35
35
|
@property
|
|
36
36
|
def percent(self) -> float:
|
|
37
37
|
if self.total_chunks == 0:
|
|
@@ -42,13 +42,14 @@ class UploadProgress:
|
|
|
42
42
|
@dataclass
|
|
43
43
|
class DownloadProgress:
|
|
44
44
|
"""Progress information for download."""
|
|
45
|
+
|
|
45
46
|
file_name: str
|
|
46
47
|
total_size: int
|
|
47
48
|
downloaded_size: int
|
|
48
49
|
total_chunks: int
|
|
49
50
|
downloaded_chunks: int
|
|
50
51
|
current_chunk: int
|
|
51
|
-
|
|
52
|
+
|
|
52
53
|
@property
|
|
53
54
|
def percent(self) -> float:
|
|
54
55
|
if self.total_chunks == 0:
|
|
@@ -62,42 +63,44 @@ ProgressCallback = Callable[[UploadProgress | DownloadProgress], None]
|
|
|
62
63
|
class TeleVault:
|
|
63
64
|
"""
|
|
64
65
|
Main TeleVault interface.
|
|
65
|
-
|
|
66
|
+
|
|
66
67
|
Handles file upload, download, listing with compression and encryption.
|
|
67
68
|
"""
|
|
68
|
-
|
|
69
|
+
|
|
69
70
|
def __init__(
|
|
70
71
|
self,
|
|
71
|
-
config:
|
|
72
|
-
telegram_config:
|
|
73
|
-
password:
|
|
72
|
+
config: Config | None = None,
|
|
73
|
+
telegram_config: TelegramConfig | None = None,
|
|
74
|
+
password: str | None = None,
|
|
74
75
|
):
|
|
75
76
|
self.config = config or Config.load_or_create()
|
|
76
77
|
self.telegram = TelegramVault(telegram_config)
|
|
77
78
|
self.password = password
|
|
78
79
|
self._connected = False
|
|
79
|
-
|
|
80
|
+
|
|
81
|
+
async def is_authenticated(self) -> bool:
|
|
82
|
+
"""Check if user is authenticated with Telegram."""
|
|
83
|
+
return await self.telegram._client.is_user_authorized()
|
|
84
|
+
|
|
80
85
|
async def connect(self, skip_channel: bool = False) -> None:
|
|
81
86
|
"""Connect to Telegram."""
|
|
82
87
|
await self.telegram.connect()
|
|
83
|
-
|
|
84
|
-
if not skip_channel and self.config.channel_id:
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
await self.telegram.set_channel(self.config.channel_id)
|
|
88
|
-
|
|
88
|
+
|
|
89
|
+
if not skip_channel and self.config.channel_id and await self.is_authenticated():
|
|
90
|
+
await self.telegram.set_channel(self.config.channel_id)
|
|
91
|
+
|
|
89
92
|
self._connected = True
|
|
90
|
-
|
|
93
|
+
|
|
91
94
|
async def disconnect(self) -> None:
|
|
92
95
|
"""Disconnect from Telegram."""
|
|
93
96
|
await self.telegram.disconnect()
|
|
94
97
|
self._connected = False
|
|
95
|
-
|
|
96
|
-
async def login(self, phone:
|
|
98
|
+
|
|
99
|
+
async def login(self, phone: str | None = None) -> str:
|
|
97
100
|
"""Interactive login flow."""
|
|
98
101
|
return await self.telegram.login(phone)
|
|
99
|
-
|
|
100
|
-
async def setup_channel(self, channel_id:
|
|
102
|
+
|
|
103
|
+
async def setup_channel(self, channel_id: int | None = None) -> int:
|
|
101
104
|
"""Set up storage channel."""
|
|
102
105
|
if channel_id:
|
|
103
106
|
await self.telegram.set_channel(channel_id)
|
|
@@ -105,38 +108,38 @@ class TeleVault:
|
|
|
105
108
|
else:
|
|
106
109
|
channel_id = await self.telegram.create_channel()
|
|
107
110
|
self.config.channel_id = channel_id
|
|
108
|
-
|
|
111
|
+
|
|
109
112
|
self.config.save()
|
|
110
113
|
return channel_id
|
|
111
|
-
|
|
114
|
+
|
|
112
115
|
async def upload(
|
|
113
116
|
self,
|
|
114
117
|
file_path: str | Path,
|
|
115
|
-
password:
|
|
116
|
-
progress_callback:
|
|
118
|
+
password: str | None = None,
|
|
119
|
+
progress_callback: ProgressCallback | None = None,
|
|
117
120
|
preserve_path: bool = False,
|
|
118
121
|
) -> FileMetadata:
|
|
119
122
|
"""
|
|
120
123
|
Upload a file to TeleVault with parallel chunk uploads.
|
|
121
|
-
|
|
124
|
+
|
|
122
125
|
Args:
|
|
123
126
|
file_path: Path to file to upload
|
|
124
127
|
password: Encryption password (uses instance password if not provided)
|
|
125
128
|
progress_callback: Optional progress callback
|
|
126
129
|
preserve_path: If True, include full path in filename (for directory uploads)
|
|
127
|
-
|
|
130
|
+
|
|
128
131
|
Returns:
|
|
129
132
|
FileMetadata of uploaded file
|
|
130
133
|
"""
|
|
131
134
|
if not self._connected:
|
|
132
135
|
raise RuntimeError("Not connected. Call connect() first.")
|
|
133
|
-
|
|
136
|
+
|
|
134
137
|
file_path = Path(file_path)
|
|
135
138
|
if not file_path.exists():
|
|
136
139
|
raise FileNotFoundError(f"File not found: {file_path}")
|
|
137
|
-
|
|
140
|
+
|
|
138
141
|
password = password or self.password
|
|
139
|
-
|
|
142
|
+
|
|
140
143
|
# Get file info
|
|
141
144
|
file_name = file_path.name
|
|
142
145
|
if preserve_path:
|
|
@@ -144,17 +147,17 @@ class TeleVault:
|
|
|
144
147
|
# For now, just use the full path
|
|
145
148
|
file_name = str(file_path)
|
|
146
149
|
file_name = file_name.replace("/", "_")
|
|
147
|
-
|
|
150
|
+
|
|
148
151
|
file_size = file_path.stat().st_size
|
|
149
152
|
file_hash = hash_file(file_path)
|
|
150
153
|
file_id = generate_file_id(file_name, file_size)
|
|
151
|
-
|
|
154
|
+
|
|
152
155
|
# Count chunks
|
|
153
156
|
chunk_size = self.config.chunk_size
|
|
154
157
|
total_chunks = (file_size + chunk_size - 1) // chunk_size
|
|
155
158
|
if total_chunks == 0:
|
|
156
159
|
total_chunks = 1 # Empty file = 1 empty chunk
|
|
157
|
-
|
|
160
|
+
|
|
158
161
|
# Create initial metadata
|
|
159
162
|
metadata = FileMetadata(
|
|
160
163
|
id=file_id,
|
|
@@ -164,114 +167,36 @@ class TeleVault:
|
|
|
164
167
|
encrypted=self.config.encryption and password is not None,
|
|
165
168
|
compressed=self.config.compression and should_compress(file_name),
|
|
166
169
|
)
|
|
167
|
-
|
|
168
|
-
# Upload metadata message first
|
|
169
|
-
metadata_msg_id = await self.telegram.upload_metadata(metadata)
|
|
170
|
-
metadata.message_id = metadata_msg_id
|
|
171
|
-
|
|
172
|
-
# Prepare chunks for parallel upload
|
|
173
|
-
chunk_results: dict[int, ChunkInfo] = {}
|
|
174
|
-
uploaded_count = 0
|
|
175
|
-
lock = asyncio.Lock()
|
|
176
|
-
|
|
177
|
-
async def upload_single_chunk(chunk):
|
|
178
|
-
nonlocal uploaded_count
|
|
179
|
-
|
|
180
|
-
data = chunk.data
|
|
181
|
-
|
|
182
|
-
# Compress if enabled
|
|
183
|
-
if metadata.compressed:
|
|
184
|
-
data = compress_data(data)
|
|
185
|
-
|
|
186
|
-
# Encrypt if enabled
|
|
187
|
-
if metadata.encrypted and password:
|
|
188
|
-
data = encrypt_chunk(data, password)
|
|
189
|
-
|
|
190
|
-
# Upload chunk
|
|
191
|
-
chunk_msg_id = await self.telegram.upload_chunk(
|
|
192
|
-
data=data,
|
|
193
|
-
filename=f"{file_id}_{chunk.index:04d}.chunk",
|
|
194
|
-
reply_to=metadata_msg_id,
|
|
195
|
-
)
|
|
196
|
-
|
|
197
|
-
# Track chunk info
|
|
198
|
-
chunk_info = ChunkInfo(
|
|
199
|
-
index=chunk.index,
|
|
200
|
-
message_id=chunk_msg_id,
|
|
201
|
-
size=len(data),
|
|
202
|
-
hash=hash_data(data),
|
|
203
|
-
)
|
|
204
|
-
|
|
205
|
-
async with lock:
|
|
206
|
-
chunk_results[chunk.index] = chunk_info
|
|
207
|
-
uploaded_count += 1
|
|
208
|
-
|
|
209
|
-
# Progress callback
|
|
210
|
-
if progress_callback:
|
|
211
|
-
progress_callback(UploadProgress(
|
|
212
|
-
file_name=file_name,
|
|
213
|
-
total_size=file_size,
|
|
214
|
-
uploaded_size=int(file_size * uploaded_count / total_chunks),
|
|
215
|
-
total_chunks=total_chunks,
|
|
216
|
-
uploaded_chunks=uploaded_count,
|
|
217
|
-
current_chunk=chunk.index,
|
|
218
|
-
))
|
|
219
|
-
|
|
220
|
-
# Upload chunks in parallel (limited concurrency)
|
|
221
|
-
semaphore = asyncio.Semaphore(self.config.parallel_uploads)
|
|
222
|
-
|
|
223
|
-
async def upload_with_limit(chunk):
|
|
224
|
-
async with semaphore:
|
|
225
|
-
await upload_single_chunk(chunk)
|
|
226
|
-
|
|
227
|
-
# Collect all chunks first for parallel processing
|
|
228
|
-
chunks = list(iter_chunks(file_path, chunk_size))
|
|
229
|
-
|
|
230
|
-
if chunks:
|
|
231
|
-
await asyncio.gather(*[upload_with_limit(c) for c in chunks])
|
|
232
|
-
|
|
233
|
-
# Sort chunks by index
|
|
234
|
-
metadata.chunks = [chunk_results[i] for i in sorted(chunk_results.keys())]
|
|
235
|
-
|
|
236
|
-
# Update metadata with chunk info
|
|
237
|
-
await self.telegram.update_metadata(metadata_msg_id, metadata)
|
|
238
|
-
|
|
239
|
-
# Update index
|
|
240
|
-
index = await self.telegram.get_index()
|
|
241
|
-
index.add_file(file_id, metadata_msg_id)
|
|
242
|
-
await self.telegram.save_index(index)
|
|
243
|
-
|
|
244
|
-
return metadata
|
|
245
|
-
|
|
170
|
+
|
|
246
171
|
# Upload metadata message first
|
|
247
172
|
metadata_msg_id = await self.telegram.upload_metadata(metadata)
|
|
248
173
|
metadata.message_id = metadata_msg_id
|
|
249
|
-
|
|
174
|
+
|
|
250
175
|
# Prepare chunks for parallel upload
|
|
251
176
|
chunk_results: dict[int, ChunkInfo] = {}
|
|
252
177
|
uploaded_count = 0
|
|
253
178
|
lock = asyncio.Lock()
|
|
254
|
-
|
|
179
|
+
|
|
255
180
|
async def upload_single_chunk(chunk):
|
|
256
181
|
nonlocal uploaded_count
|
|
257
|
-
|
|
182
|
+
|
|
258
183
|
data = chunk.data
|
|
259
|
-
|
|
184
|
+
|
|
260
185
|
# Compress if enabled
|
|
261
186
|
if metadata.compressed:
|
|
262
187
|
data = compress_data(data)
|
|
263
|
-
|
|
188
|
+
|
|
264
189
|
# Encrypt if enabled
|
|
265
190
|
if metadata.encrypted and password:
|
|
266
191
|
data = encrypt_chunk(data, password)
|
|
267
|
-
|
|
192
|
+
|
|
268
193
|
# Upload chunk
|
|
269
194
|
chunk_msg_id = await self.telegram.upload_chunk(
|
|
270
195
|
data=data,
|
|
271
196
|
filename=f"{file_id}_{chunk.index:04d}.chunk",
|
|
272
197
|
reply_to=metadata_msg_id,
|
|
273
198
|
)
|
|
274
|
-
|
|
199
|
+
|
|
275
200
|
# Track chunk info
|
|
276
201
|
chunk_info = ChunkInfo(
|
|
277
202
|
index=chunk.index,
|
|
@@ -279,75 +204,77 @@ class TeleVault:
|
|
|
279
204
|
size=len(data),
|
|
280
205
|
hash=hash_data(data),
|
|
281
206
|
)
|
|
282
|
-
|
|
207
|
+
|
|
283
208
|
async with lock:
|
|
284
209
|
chunk_results[chunk.index] = chunk_info
|
|
285
210
|
uploaded_count += 1
|
|
286
|
-
|
|
211
|
+
|
|
287
212
|
# Progress callback
|
|
288
213
|
if progress_callback:
|
|
289
|
-
progress_callback(
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
214
|
+
progress_callback(
|
|
215
|
+
UploadProgress(
|
|
216
|
+
file_name=file_name,
|
|
217
|
+
total_size=file_size,
|
|
218
|
+
uploaded_size=int(file_size * uploaded_count / total_chunks),
|
|
219
|
+
total_chunks=total_chunks,
|
|
220
|
+
uploaded_chunks=uploaded_count,
|
|
221
|
+
current_chunk=chunk.index,
|
|
222
|
+
)
|
|
223
|
+
)
|
|
224
|
+
|
|
298
225
|
# Upload chunks in parallel (limited concurrency)
|
|
299
226
|
semaphore = asyncio.Semaphore(self.config.parallel_uploads)
|
|
300
|
-
|
|
227
|
+
|
|
301
228
|
async def upload_with_limit(chunk):
|
|
302
229
|
async with semaphore:
|
|
303
230
|
await upload_single_chunk(chunk)
|
|
304
|
-
|
|
231
|
+
|
|
305
232
|
# Collect all chunks first for parallel processing
|
|
306
233
|
chunks = list(iter_chunks(file_path, chunk_size))
|
|
307
|
-
|
|
234
|
+
|
|
308
235
|
if chunks:
|
|
309
236
|
await asyncio.gather(*[upload_with_limit(c) for c in chunks])
|
|
310
|
-
|
|
237
|
+
|
|
311
238
|
# Sort chunks by index
|
|
312
239
|
metadata.chunks = [chunk_results[i] for i in sorted(chunk_results.keys())]
|
|
313
|
-
|
|
240
|
+
|
|
314
241
|
# Update metadata with chunk info
|
|
315
242
|
await self.telegram.update_metadata(metadata_msg_id, metadata)
|
|
316
|
-
|
|
243
|
+
|
|
317
244
|
# Update index
|
|
318
245
|
index = await self.telegram.get_index()
|
|
319
246
|
index.add_file(file_id, metadata_msg_id)
|
|
320
247
|
await self.telegram.save_index(index)
|
|
321
|
-
|
|
248
|
+
|
|
322
249
|
return metadata
|
|
323
|
-
|
|
250
|
+
|
|
324
251
|
async def download(
|
|
325
252
|
self,
|
|
326
253
|
file_id_or_name: str,
|
|
327
|
-
output_path:
|
|
328
|
-
password:
|
|
329
|
-
progress_callback:
|
|
254
|
+
output_path: str | Path | None = None,
|
|
255
|
+
password: str | None = None,
|
|
256
|
+
progress_callback: ProgressCallback | None = None,
|
|
330
257
|
) -> Path:
|
|
331
258
|
"""
|
|
332
259
|
Download a file from TeleVault.
|
|
333
|
-
|
|
260
|
+
|
|
334
261
|
Args:
|
|
335
262
|
file_id_or_name: File ID or name to download
|
|
336
263
|
output_path: Output path (uses original filename in current dir if not provided)
|
|
337
264
|
password: Decryption password
|
|
338
265
|
progress_callback: Optional progress callback
|
|
339
|
-
|
|
266
|
+
|
|
340
267
|
Returns:
|
|
341
268
|
Path to downloaded file
|
|
342
269
|
"""
|
|
343
270
|
if not self._connected:
|
|
344
271
|
raise RuntimeError("Not connected. Call connect() first.")
|
|
345
|
-
|
|
272
|
+
|
|
346
273
|
password = password or self.password
|
|
347
|
-
|
|
274
|
+
|
|
348
275
|
# Find file
|
|
349
276
|
index = await self.telegram.get_index()
|
|
350
|
-
|
|
277
|
+
|
|
351
278
|
# Try as file ID first
|
|
352
279
|
if file_id_or_name in index.files:
|
|
353
280
|
metadata_msg_id = index.files[file_id_or_name]
|
|
@@ -355,121 +282,131 @@ class TeleVault:
|
|
|
355
282
|
# Search by name
|
|
356
283
|
files = await self.telegram.list_files()
|
|
357
284
|
matches = [f for f in files if f.name == file_id_or_name or file_id_or_name in f.name]
|
|
358
|
-
|
|
285
|
+
|
|
359
286
|
if not matches:
|
|
360
287
|
raise FileNotFoundError(f"File not found: {file_id_or_name}")
|
|
361
288
|
if len(matches) > 1:
|
|
362
|
-
raise ValueError(
|
|
363
|
-
|
|
289
|
+
raise ValueError(
|
|
290
|
+
f"Multiple files match '{file_id_or_name}': {[f.name for f in matches]}"
|
|
291
|
+
)
|
|
292
|
+
|
|
364
293
|
metadata_msg_id = matches[0].message_id
|
|
365
|
-
|
|
294
|
+
|
|
366
295
|
# Get metadata
|
|
367
296
|
metadata = await self.telegram.get_metadata(metadata_msg_id)
|
|
368
|
-
|
|
297
|
+
|
|
369
298
|
# Determine output path
|
|
370
|
-
if output_path
|
|
371
|
-
|
|
372
|
-
else:
|
|
373
|
-
output_path = Path.cwd() / metadata.name
|
|
374
|
-
|
|
299
|
+
output_path = Path(output_path) if output_path else Path.cwd() / metadata.name
|
|
300
|
+
|
|
375
301
|
# Create chunk writer
|
|
376
302
|
writer = ChunkWriter(output_path, metadata.size, self.config.chunk_size)
|
|
377
|
-
|
|
303
|
+
|
|
378
304
|
downloaded_size = 0
|
|
379
|
-
|
|
305
|
+
total_chunks = len(metadata.chunks)
|
|
306
|
+
|
|
380
307
|
# Download chunks in order
|
|
381
|
-
for chunk_info in
|
|
308
|
+
for downloaded_chunks, chunk_info in enumerate(
|
|
309
|
+
sorted(metadata.chunks, key=lambda c: c.index), start=1
|
|
310
|
+
):
|
|
382
311
|
# Download chunk
|
|
383
312
|
data = await self.telegram.download_chunk(chunk_info.message_id)
|
|
384
|
-
|
|
313
|
+
|
|
385
314
|
# Verify hash
|
|
386
315
|
if hash_data(data) != chunk_info.hash:
|
|
387
316
|
raise ValueError(f"Chunk {chunk_info.index} hash mismatch - data corrupted")
|
|
388
|
-
|
|
317
|
+
|
|
389
318
|
# Decrypt if needed
|
|
390
319
|
if metadata.encrypted:
|
|
391
320
|
if not password:
|
|
392
321
|
raise ValueError("File is encrypted but no password provided")
|
|
393
322
|
data = decrypt_chunk(data, password)
|
|
394
|
-
|
|
323
|
+
|
|
395
324
|
# Decompress if needed
|
|
396
325
|
if metadata.compressed:
|
|
397
326
|
data = decompress_data(data)
|
|
398
|
-
|
|
327
|
+
|
|
399
328
|
# Write chunk
|
|
400
329
|
from .chunker import Chunk
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
330
|
+
|
|
331
|
+
writer.write_chunk(
|
|
332
|
+
Chunk(
|
|
333
|
+
index=chunk_info.index,
|
|
334
|
+
data=data,
|
|
335
|
+
hash="", # Already verified
|
|
336
|
+
size=len(data),
|
|
337
|
+
)
|
|
338
|
+
)
|
|
339
|
+
|
|
408
340
|
downloaded_size += len(data)
|
|
409
|
-
|
|
341
|
+
|
|
410
342
|
# Progress callback
|
|
411
343
|
if progress_callback:
|
|
412
|
-
progress_callback(
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
344
|
+
progress_callback(
|
|
345
|
+
DownloadProgress(
|
|
346
|
+
file_name=metadata.name,
|
|
347
|
+
total_size=metadata.size,
|
|
348
|
+
downloaded_size=downloaded_size,
|
|
349
|
+
total_chunks=total_chunks,
|
|
350
|
+
downloaded_chunks=downloaded_chunks,
|
|
351
|
+
current_chunk=chunk_info.index,
|
|
352
|
+
)
|
|
353
|
+
)
|
|
354
|
+
|
|
421
355
|
# Verify final hash
|
|
422
356
|
if hash_file(output_path) != metadata.hash:
|
|
423
|
-
output_path.unlink() # Delete corrupted file
|
|
424
|
-
raise ValueError(
|
|
425
|
-
|
|
357
|
+
output_path.unlink(missing_ok=True) # Delete corrupted file if it exists
|
|
358
|
+
raise ValueError(
|
|
359
|
+
"Downloaded file hash mismatch - downloaded data is corrupted; "
|
|
360
|
+
"try re-downloading or checking your network/Telegram storage."
|
|
361
|
+
)
|
|
362
|
+
|
|
426
363
|
return output_path
|
|
427
|
-
|
|
364
|
+
|
|
428
365
|
async def list_files(self) -> list[FileMetadata]:
|
|
429
366
|
"""List all files in the vault."""
|
|
430
367
|
if not self._connected:
|
|
431
368
|
raise RuntimeError("Not connected. Call connect() first.")
|
|
432
|
-
|
|
369
|
+
|
|
433
370
|
return await self.telegram.list_files()
|
|
434
|
-
|
|
371
|
+
|
|
435
372
|
async def search(self, query: str) -> list[FileMetadata]:
|
|
436
373
|
"""Search files by name."""
|
|
437
374
|
if not self._connected:
|
|
438
375
|
raise RuntimeError("Not connected. Call connect() first.")
|
|
439
|
-
|
|
376
|
+
|
|
440
377
|
return await self.telegram.search_files(query)
|
|
441
|
-
|
|
378
|
+
|
|
442
379
|
async def delete(self, file_id_or_name: str) -> bool:
|
|
443
380
|
"""Delete a file."""
|
|
444
381
|
if not self._connected:
|
|
445
382
|
raise RuntimeError("Not connected. Call connect() first.")
|
|
446
|
-
|
|
383
|
+
|
|
447
384
|
index = await self.telegram.get_index()
|
|
448
|
-
|
|
385
|
+
|
|
449
386
|
# Try as file ID first
|
|
450
387
|
if file_id_or_name in index.files:
|
|
451
388
|
return await self.telegram.delete_file(file_id_or_name)
|
|
452
|
-
|
|
389
|
+
|
|
453
390
|
# Search by name
|
|
454
391
|
files = await self.telegram.list_files()
|
|
455
392
|
matches = [f for f in files if f.name == file_id_or_name]
|
|
456
|
-
|
|
393
|
+
|
|
457
394
|
if not matches:
|
|
458
395
|
return False
|
|
459
396
|
if len(matches) > 1:
|
|
460
397
|
raise ValueError(f"Multiple files match '{file_id_or_name}'")
|
|
461
|
-
|
|
398
|
+
|
|
462
399
|
return await self.telegram.delete_file(matches[0].id)
|
|
463
|
-
|
|
400
|
+
|
|
464
401
|
async def get_status(self) -> dict:
|
|
465
402
|
"""Get vault status."""
|
|
466
403
|
if not self._connected:
|
|
467
404
|
raise RuntimeError("Not connected. Call connect() first.")
|
|
468
|
-
|
|
405
|
+
|
|
469
406
|
files = await self.list_files()
|
|
470
407
|
total_size = sum(f.size for f in files)
|
|
471
408
|
stored_size = sum(f.total_stored_size for f in files)
|
|
472
|
-
|
|
409
|
+
|
|
473
410
|
return {
|
|
474
411
|
"channel_id": self.config.channel_id,
|
|
475
412
|
"file_count": len(files),
|