televault 0.1.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 +16 -0
- televault/chunker.py +189 -0
- televault/cli.py +445 -0
- televault/compress.py +138 -0
- televault/config.py +81 -0
- televault/core.py +479 -0
- televault/crypto.py +170 -0
- televault/models.py +149 -0
- televault/telegram.py +375 -0
- televault-0.1.0.dist-info/METADATA +242 -0
- televault-0.1.0.dist-info/RECORD +13 -0
- televault-0.1.0.dist-info/WHEEL +4 -0
- televault-0.1.0.dist-info/entry_points.txt +3 -0
televault/__init__.py
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""
|
|
2
|
+
TeleVault - Unlimited cloud storage using Telegram MTProto.
|
|
3
|
+
|
|
4
|
+
Features:
|
|
5
|
+
- MTProto Direct (no bot API limits)
|
|
6
|
+
- Zero Local DB (metadata on Telegram)
|
|
7
|
+
- Client-side Encryption (AES-256-GCM)
|
|
8
|
+
- Parallel Processing
|
|
9
|
+
- Folder Support
|
|
10
|
+
- TUI + CLI
|
|
11
|
+
"""
|
|
12
|
+
__version__ = "1.0.0"
|
|
13
|
+
__author__ = "Yahya Toubali"
|
|
14
|
+
__email__ = "yahya@yahyatoubali.me"
|
|
15
|
+
__license__ = "MIT"
|
|
16
|
+
__url__ = "https://github.com/yahyatoubali/televault"
|
televault/chunker.py
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
"""File chunking utilities for TeleVault."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Iterator, BinaryIO
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
|
|
8
|
+
import blake3
|
|
9
|
+
|
|
10
|
+
# Telegram limits: 2GB per file via MTProto
|
|
11
|
+
# Using 100MB chunks for better parallelism and resume capability
|
|
12
|
+
DEFAULT_CHUNK_SIZE = 100 * 1024 * 1024 # 100MB
|
|
13
|
+
MAX_CHUNK_SIZE = 2000 * 1024 * 1024 # ~2GB (with margin)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass
|
|
17
|
+
class Chunk:
|
|
18
|
+
"""A chunk of file data ready for upload."""
|
|
19
|
+
|
|
20
|
+
index: int
|
|
21
|
+
data: bytes
|
|
22
|
+
hash: str
|
|
23
|
+
size: int
|
|
24
|
+
|
|
25
|
+
@property
|
|
26
|
+
def filename(self) -> str:
|
|
27
|
+
"""Generate chunk filename."""
|
|
28
|
+
return f"{self.index:04d}.chunk"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def hash_data(data: bytes) -> str:
|
|
32
|
+
"""Compute BLAKE3 hash of data (fast, secure)."""
|
|
33
|
+
return blake3.blake3(data).hexdigest()[:32] # 128-bit prefix
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def hash_file(path: str | Path) -> str:
|
|
37
|
+
"""Compute BLAKE3 hash of entire file (streaming)."""
|
|
38
|
+
hasher = blake3.blake3()
|
|
39
|
+
with open(path, "rb") as f:
|
|
40
|
+
while chunk := f.read(8192):
|
|
41
|
+
hasher.update(chunk)
|
|
42
|
+
return hasher.hexdigest()[:32]
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def get_file_size(path: str | Path) -> int:
|
|
46
|
+
"""Get file size in bytes."""
|
|
47
|
+
return os.path.getsize(path)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def iter_chunks(
|
|
51
|
+
file_path: str | Path,
|
|
52
|
+
chunk_size: int = DEFAULT_CHUNK_SIZE,
|
|
53
|
+
) -> Iterator[Chunk]:
|
|
54
|
+
"""
|
|
55
|
+
Split a file into chunks.
|
|
56
|
+
|
|
57
|
+
Yields Chunk objects with index, data, hash, and size.
|
|
58
|
+
Memory-efficient: only one chunk in memory at a time.
|
|
59
|
+
"""
|
|
60
|
+
if chunk_size > MAX_CHUNK_SIZE:
|
|
61
|
+
raise ValueError(f"Chunk size {chunk_size} exceeds max {MAX_CHUNK_SIZE}")
|
|
62
|
+
|
|
63
|
+
with open(file_path, "rb") as f:
|
|
64
|
+
index = 0
|
|
65
|
+
while True:
|
|
66
|
+
data = f.read(chunk_size)
|
|
67
|
+
if not data:
|
|
68
|
+
break
|
|
69
|
+
|
|
70
|
+
yield Chunk(
|
|
71
|
+
index=index,
|
|
72
|
+
data=data,
|
|
73
|
+
hash=hash_data(data),
|
|
74
|
+
size=len(data),
|
|
75
|
+
)
|
|
76
|
+
index += 1
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def count_chunks(file_size: int, chunk_size: int = DEFAULT_CHUNK_SIZE) -> int:
|
|
80
|
+
"""Calculate number of chunks for a file."""
|
|
81
|
+
if file_size == 0:
|
|
82
|
+
return 0
|
|
83
|
+
return (file_size + chunk_size - 1) // chunk_size
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def read_chunk(
|
|
87
|
+
file_path: str | Path,
|
|
88
|
+
index: int,
|
|
89
|
+
chunk_size: int = DEFAULT_CHUNK_SIZE,
|
|
90
|
+
) -> Chunk:
|
|
91
|
+
"""Read a specific chunk by index."""
|
|
92
|
+
with open(file_path, "rb") as f:
|
|
93
|
+
f.seek(index * chunk_size)
|
|
94
|
+
data = f.read(chunk_size)
|
|
95
|
+
if not data:
|
|
96
|
+
raise ValueError(f"Chunk {index} is empty or out of range")
|
|
97
|
+
|
|
98
|
+
return Chunk(
|
|
99
|
+
index=index,
|
|
100
|
+
data=data,
|
|
101
|
+
hash=hash_data(data),
|
|
102
|
+
size=len(data),
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
class ChunkWriter:
|
|
107
|
+
"""
|
|
108
|
+
Reassemble chunks into a file.
|
|
109
|
+
|
|
110
|
+
Handles out-of-order chunks by writing to correct positions.
|
|
111
|
+
"""
|
|
112
|
+
|
|
113
|
+
def __init__(self, output_path: str | Path, total_size: int, chunk_size: int = DEFAULT_CHUNK_SIZE):
|
|
114
|
+
self.output_path = Path(output_path)
|
|
115
|
+
self.total_size = total_size
|
|
116
|
+
self.chunk_size = chunk_size
|
|
117
|
+
self.written_chunks: set[int] = set()
|
|
118
|
+
|
|
119
|
+
# Pre-allocate file
|
|
120
|
+
self.output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
121
|
+
with open(self.output_path, "wb") as f:
|
|
122
|
+
f.truncate(total_size)
|
|
123
|
+
|
|
124
|
+
def write_chunk(self, chunk: Chunk) -> None:
|
|
125
|
+
"""Write a chunk to the correct position."""
|
|
126
|
+
if chunk.index in self.written_chunks:
|
|
127
|
+
return # Already written
|
|
128
|
+
|
|
129
|
+
offset = chunk.index * self.chunk_size
|
|
130
|
+
with open(self.output_path, "r+b") as f:
|
|
131
|
+
f.seek(offset)
|
|
132
|
+
f.write(chunk.data)
|
|
133
|
+
|
|
134
|
+
self.written_chunks.add(chunk.index)
|
|
135
|
+
|
|
136
|
+
def is_complete(self, expected_chunks: int) -> bool:
|
|
137
|
+
"""Check if all chunks have been written."""
|
|
138
|
+
return len(self.written_chunks) == expected_chunks
|
|
139
|
+
|
|
140
|
+
def missing_chunks(self, expected_chunks: int) -> list[int]:
|
|
141
|
+
"""Get list of missing chunk indices."""
|
|
142
|
+
return [i for i in range(expected_chunks) if i not in self.written_chunks]
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
class ChunkBuffer:
|
|
146
|
+
"""
|
|
147
|
+
Buffer for streaming chunk creation.
|
|
148
|
+
|
|
149
|
+
Useful when reading from a stream (network, compression, encryption)
|
|
150
|
+
rather than a file.
|
|
151
|
+
"""
|
|
152
|
+
|
|
153
|
+
def __init__(self, chunk_size: int = DEFAULT_CHUNK_SIZE):
|
|
154
|
+
self.chunk_size = chunk_size
|
|
155
|
+
self.buffer = bytearray()
|
|
156
|
+
self.index = 0
|
|
157
|
+
|
|
158
|
+
def write(self, data: bytes) -> Iterator[Chunk]:
|
|
159
|
+
"""
|
|
160
|
+
Write data to buffer, yielding complete chunks.
|
|
161
|
+
"""
|
|
162
|
+
self.buffer.extend(data)
|
|
163
|
+
|
|
164
|
+
while len(self.buffer) >= self.chunk_size:
|
|
165
|
+
chunk_data = bytes(self.buffer[:self.chunk_size])
|
|
166
|
+
self.buffer = self.buffer[self.chunk_size:]
|
|
167
|
+
|
|
168
|
+
yield Chunk(
|
|
169
|
+
index=self.index,
|
|
170
|
+
data=chunk_data,
|
|
171
|
+
hash=hash_data(chunk_data),
|
|
172
|
+
size=len(chunk_data),
|
|
173
|
+
)
|
|
174
|
+
self.index += 1
|
|
175
|
+
|
|
176
|
+
def flush(self) -> Chunk | None:
|
|
177
|
+
"""Flush remaining data as final chunk."""
|
|
178
|
+
if not self.buffer:
|
|
179
|
+
return None
|
|
180
|
+
|
|
181
|
+
chunk_data = bytes(self.buffer)
|
|
182
|
+
self.buffer.clear()
|
|
183
|
+
|
|
184
|
+
return Chunk(
|
|
185
|
+
index=self.index,
|
|
186
|
+
data=chunk_data,
|
|
187
|
+
hash=hash_data(chunk_data),
|
|
188
|
+
size=len(chunk_data),
|
|
189
|
+
)
|
televault/cli.py
ADDED
|
@@ -0,0 +1,445 @@
|
|
|
1
|
+
"""TeleVault CLI - Command line interface."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import sys
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Optional
|
|
7
|
+
|
|
8
|
+
import click
|
|
9
|
+
from rich.console import Console
|
|
10
|
+
from rich.table import Table
|
|
11
|
+
from rich.progress import Progress, SpinnerColumn, BarColumn, TextColumn, TimeRemainingColumn
|
|
12
|
+
from rich import print as rprint
|
|
13
|
+
|
|
14
|
+
from .core import TeleVault, UploadProgress, DownloadProgress
|
|
15
|
+
from .config import Config, get_config_dir
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
console = Console()
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def format_size(size: int) -> str:
|
|
22
|
+
"""Format bytes as human readable."""
|
|
23
|
+
for unit in ["B", "KB", "MB", "GB", "TB"]:
|
|
24
|
+
if size < 1024:
|
|
25
|
+
return f"{size:.1f} {unit}"
|
|
26
|
+
size /= 1024
|
|
27
|
+
return f"{size:.1f} PB"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def run_async(coro):
|
|
31
|
+
"""Run async function."""
|
|
32
|
+
return asyncio.get_event_loop().run_until_complete(coro)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@click.group(invoke_without_command=True)
|
|
36
|
+
@click.option("-h", "--help", is_flag=True, help="Show this message and exit.")
|
|
37
|
+
@click.pass_context
|
|
38
|
+
def main(ctx, help):
|
|
39
|
+
"""TeleVault - Unlimited cloud storage using Telegram."""
|
|
40
|
+
if help or ctx.invoked_subcommand is None:
|
|
41
|
+
click.echo(ctx.get_help())
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@main.command()
|
|
45
|
+
@click.option("--phone", "-p", help="Phone number for login")
|
|
46
|
+
def login(phone: Optional[str]):
|
|
47
|
+
"""Login to Telegram."""
|
|
48
|
+
async def _login():
|
|
49
|
+
vault = TeleVault()
|
|
50
|
+
await vault.connect(skip_channel=True) # Don't try to access channel yet
|
|
51
|
+
|
|
52
|
+
console.print("[bold blue]TeleVault Login[/bold blue]")
|
|
53
|
+
console.print("You'll receive a code on Telegram.\n")
|
|
54
|
+
|
|
55
|
+
session = await vault.login(phone)
|
|
56
|
+
|
|
57
|
+
console.print("\n[bold green]✓ Login successful![/bold green]")
|
|
58
|
+
console.print(f"Session saved to: {get_config_dir() / 'telegram.json'}")
|
|
59
|
+
|
|
60
|
+
# Now set up channel if configured
|
|
61
|
+
if vault.config.channel_id:
|
|
62
|
+
await vault.telegram.set_channel(vault.config.channel_id)
|
|
63
|
+
console.print(f"Channel configured: {vault.config.channel_id}")
|
|
64
|
+
|
|
65
|
+
await vault.disconnect()
|
|
66
|
+
|
|
67
|
+
run_async(_login())
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@main.command()
|
|
71
|
+
def logout():
|
|
72
|
+
"""Logout and clear session."""
|
|
73
|
+
config_dir = get_config_dir()
|
|
74
|
+
telegram_config = config_dir / "telegram.json"
|
|
75
|
+
|
|
76
|
+
if telegram_config.exists():
|
|
77
|
+
telegram_config.unlink()
|
|
78
|
+
console.print("[green]✓ Logged out successfully[/green]")
|
|
79
|
+
else:
|
|
80
|
+
console.print("[yellow]Not logged in[/yellow]")
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@main.command()
|
|
84
|
+
@click.option("--channel-id", "-c", type=int, help="Existing channel ID to use")
|
|
85
|
+
def setup(channel_id: Optional[int]):
|
|
86
|
+
"""Set up storage channel."""
|
|
87
|
+
async def _setup():
|
|
88
|
+
vault = TeleVault()
|
|
89
|
+
await vault.connect()
|
|
90
|
+
|
|
91
|
+
if channel_id:
|
|
92
|
+
cid = await vault.setup_channel(channel_id)
|
|
93
|
+
console.print(f"[green]✓ Using channel: {cid}[/green]")
|
|
94
|
+
else:
|
|
95
|
+
console.print("[bold]Creating new storage channel...[/bold]")
|
|
96
|
+
cid = await vault.setup_channel()
|
|
97
|
+
console.print(f"[green]✓ Created channel: {cid}[/green]")
|
|
98
|
+
|
|
99
|
+
await vault.disconnect()
|
|
100
|
+
|
|
101
|
+
run_async(_setup())
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
@main.command()
|
|
105
|
+
@click.argument("file_path", type=click.Path(exists=True))
|
|
106
|
+
@click.option("--password", "-p", help="Encryption password", envvar="TELEVAULT_PASSWORD")
|
|
107
|
+
@click.option("--no-compress", is_flag=True, help="Disable compression")
|
|
108
|
+
@click.option("--no-encrypt", is_flag=True, help="Disable encryption")
|
|
109
|
+
@click.option("--recursive", "-r", is_flag=True, help="Upload directory recursively")
|
|
110
|
+
def push(file_path: str, password: Optional[str], no_compress: bool, no_encrypt: bool, recursive: bool):
|
|
111
|
+
"""Upload a file or directory to TeleVault."""
|
|
112
|
+
async def _push():
|
|
113
|
+
config = Config.load_or_create()
|
|
114
|
+
|
|
115
|
+
if no_compress:
|
|
116
|
+
config.compression = False
|
|
117
|
+
if no_encrypt:
|
|
118
|
+
config.encryption = False
|
|
119
|
+
|
|
120
|
+
if config.encryption and not password:
|
|
121
|
+
console.print("[yellow]Warning: Encryption enabled but no password provided.[/yellow]")
|
|
122
|
+
console.print("Set password with --password or TELEVAULT_PASSWORD env var.")
|
|
123
|
+
console.print("Use --no-encrypt to disable encryption.\n")
|
|
124
|
+
|
|
125
|
+
vault = TeleVault(config=config, password=password)
|
|
126
|
+
await vault.connect()
|
|
127
|
+
|
|
128
|
+
file_path_obj = Path(file_path)
|
|
129
|
+
|
|
130
|
+
# Handle directory upload
|
|
131
|
+
if file_path_obj.is_dir():
|
|
132
|
+
if not recursive:
|
|
133
|
+
console.print(f"[red]'{file_path}' is a directory. Use --recursive (-r) to upload.[/red]")
|
|
134
|
+
await vault.disconnect()
|
|
135
|
+
return
|
|
136
|
+
|
|
137
|
+
files = list(file_path_obj.rglob("*"))
|
|
138
|
+
files = [f for f in files if f.is_file()]
|
|
139
|
+
|
|
140
|
+
if not files:
|
|
141
|
+
console.print("[yellow]No files found in directory.[/yellow]")
|
|
142
|
+
await vault.disconnect()
|
|
143
|
+
return
|
|
144
|
+
|
|
145
|
+
console.print(f"[bold]Uploading {len(files)} files from {file_path_obj.name}/[/bold]\n")
|
|
146
|
+
|
|
147
|
+
for i, f in enumerate(files, 1):
|
|
148
|
+
rel_path = f.relative_to(file_path_obj)
|
|
149
|
+
console.print(f"[{i}/{len(files)}] {rel_path}...", end=" ")
|
|
150
|
+
try:
|
|
151
|
+
metadata = await vault.upload(f)
|
|
152
|
+
console.print(f"[green]✓[/green] ({format_size(metadata.size)})")
|
|
153
|
+
except Exception as e:
|
|
154
|
+
console.print(f"[red]✗ {e}[/red]")
|
|
155
|
+
|
|
156
|
+
console.print(f"\n[bold green]✓ Uploaded {len(files)} files[/bold green]")
|
|
157
|
+
else:
|
|
158
|
+
# Single file upload with progress
|
|
159
|
+
file_size = file_path_obj.stat().st_size
|
|
160
|
+
|
|
161
|
+
with Progress(
|
|
162
|
+
SpinnerColumn(),
|
|
163
|
+
TextColumn("[progress.description]{task.description}"),
|
|
164
|
+
BarColumn(),
|
|
165
|
+
TextColumn("[progress.percentage]{task.percentage:>3.0f}%"),
|
|
166
|
+
TextColumn("({task.fields[size]})"),
|
|
167
|
+
TimeRemainingColumn(),
|
|
168
|
+
console=console,
|
|
169
|
+
refresh_per_second=10,
|
|
170
|
+
) as progress:
|
|
171
|
+
task = progress.add_task(
|
|
172
|
+
f"Uploading {file_path_obj.name}",
|
|
173
|
+
total=100,
|
|
174
|
+
size=format_size(file_size)
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
def on_progress(p: UploadProgress):
|
|
178
|
+
progress.update(task, completed=p.percent)
|
|
179
|
+
|
|
180
|
+
metadata = await vault.upload(file_path, progress_callback=on_progress)
|
|
181
|
+
progress.update(task, completed=100) # Ensure 100% at end
|
|
182
|
+
|
|
183
|
+
console.print(f"\n[bold green]✓ Uploaded successfully![/bold green]")
|
|
184
|
+
console.print(f" File ID: {metadata.id}")
|
|
185
|
+
console.print(f" Size: {format_size(metadata.size)}")
|
|
186
|
+
console.print(f" Chunks: {metadata.chunk_count}")
|
|
187
|
+
console.print(f" Encrypted: {'Yes' if metadata.encrypted else 'No'}")
|
|
188
|
+
console.print(f" Compressed: {'Yes' if metadata.compressed else 'No'}")
|
|
189
|
+
|
|
190
|
+
await vault.disconnect()
|
|
191
|
+
|
|
192
|
+
run_async(_push())
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
@main.command()
|
|
196
|
+
@click.argument("file_id_or_name")
|
|
197
|
+
@click.option("--output", "-o", type=click.Path(), help="Output path")
|
|
198
|
+
@click.option("--password", "-p", help="Decryption password", envvar="TELEVAULT_PASSWORD")
|
|
199
|
+
def pull(file_id_or_name: str, output: Optional[str], password: Optional[str]):
|
|
200
|
+
"""Download a file from TeleVault."""
|
|
201
|
+
async def _pull():
|
|
202
|
+
vault = TeleVault(password=password)
|
|
203
|
+
await vault.connect()
|
|
204
|
+
|
|
205
|
+
with Progress(
|
|
206
|
+
SpinnerColumn(),
|
|
207
|
+
TextColumn("[progress.description]{task.description}"),
|
|
208
|
+
BarColumn(),
|
|
209
|
+
TextColumn("[progress.percentage]{task.percentage:>3.0f}%"),
|
|
210
|
+
TimeRemainingColumn(),
|
|
211
|
+
console=console,
|
|
212
|
+
refresh_per_second=10,
|
|
213
|
+
) as progress:
|
|
214
|
+
task = progress.add_task(f"Downloading {file_id_or_name}", total=100)
|
|
215
|
+
|
|
216
|
+
def on_progress(p: DownloadProgress):
|
|
217
|
+
progress.update(task, completed=p.percent)
|
|
218
|
+
|
|
219
|
+
try:
|
|
220
|
+
output_path = await vault.download(
|
|
221
|
+
file_id_or_name,
|
|
222
|
+
output_path=output,
|
|
223
|
+
progress_callback=on_progress,
|
|
224
|
+
)
|
|
225
|
+
progress.update(task, completed=100) # Ensure 100% at end
|
|
226
|
+
except FileNotFoundError:
|
|
227
|
+
console.print(f"[red]✗ File not found: {file_id_or_name}[/red]")
|
|
228
|
+
await vault.disconnect()
|
|
229
|
+
sys.exit(1)
|
|
230
|
+
except ValueError as e:
|
|
231
|
+
console.print(f"[red]✗ Error: {e}[/red]")
|
|
232
|
+
await vault.disconnect()
|
|
233
|
+
sys.exit(1)
|
|
234
|
+
|
|
235
|
+
console.print(f"\n[bold green]✓ Downloaded to: {output_path}[/bold green]")
|
|
236
|
+
|
|
237
|
+
await vault.disconnect()
|
|
238
|
+
|
|
239
|
+
run_async(_pull())
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
@main.command(name="ls")
|
|
243
|
+
@click.option("--json", "as_json", is_flag=True, help="Output as JSON")
|
|
244
|
+
@click.option("--sort", type=click.Choice(["name", "size", "date"]), default="name")
|
|
245
|
+
def list_files(as_json: bool, sort: str):
|
|
246
|
+
"""List all files in the vault."""
|
|
247
|
+
async def _list():
|
|
248
|
+
vault = TeleVault()
|
|
249
|
+
await vault.connect()
|
|
250
|
+
|
|
251
|
+
files = await vault.list_files()
|
|
252
|
+
|
|
253
|
+
# Sort
|
|
254
|
+
if sort == "name":
|
|
255
|
+
files.sort(key=lambda f: f.name.lower())
|
|
256
|
+
elif sort == "size":
|
|
257
|
+
files.sort(key=lambda f: f.size, reverse=True)
|
|
258
|
+
elif sort == "date":
|
|
259
|
+
files.sort(key=lambda f: f.created_at, reverse=True)
|
|
260
|
+
|
|
261
|
+
if as_json:
|
|
262
|
+
import json
|
|
263
|
+
output = [{"id": f.id, "name": f.name, "size": f.size} for f in files]
|
|
264
|
+
click.echo(json.dumps(output, indent=2))
|
|
265
|
+
else:
|
|
266
|
+
if not files:
|
|
267
|
+
console.print("[dim]No files in vault[/dim]")
|
|
268
|
+
else:
|
|
269
|
+
table = Table(title="TeleVault Files")
|
|
270
|
+
table.add_column("ID", style="dim")
|
|
271
|
+
table.add_column("Name")
|
|
272
|
+
table.add_column("Size", justify="right")
|
|
273
|
+
table.add_column("Chunks", justify="right")
|
|
274
|
+
table.add_column("Encrypted")
|
|
275
|
+
|
|
276
|
+
for f in files:
|
|
277
|
+
table.add_row(
|
|
278
|
+
f.id[:8],
|
|
279
|
+
f.name,
|
|
280
|
+
format_size(f.size),
|
|
281
|
+
str(f.chunk_count),
|
|
282
|
+
"🔒" if f.encrypted else "📄",
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
console.print(table)
|
|
286
|
+
console.print(f"\n[dim]{len(files)} file(s), {format_size(sum(f.size for f in files))} total[/dim]")
|
|
287
|
+
|
|
288
|
+
await vault.disconnect()
|
|
289
|
+
|
|
290
|
+
run_async(_list())
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
@main.command()
|
|
294
|
+
@click.argument("query")
|
|
295
|
+
def search(query: str):
|
|
296
|
+
"""Search files by name."""
|
|
297
|
+
async def _search():
|
|
298
|
+
vault = TeleVault()
|
|
299
|
+
await vault.connect()
|
|
300
|
+
|
|
301
|
+
files = await vault.search(query)
|
|
302
|
+
|
|
303
|
+
if not files:
|
|
304
|
+
console.print(f"[dim]No files matching '{query}'[/dim]")
|
|
305
|
+
else:
|
|
306
|
+
for f in files:
|
|
307
|
+
console.print(f"[cyan]{f.id[:8]}[/cyan] {f.name} ({format_size(f.size)})")
|
|
308
|
+
|
|
309
|
+
await vault.disconnect()
|
|
310
|
+
|
|
311
|
+
run_async(_search())
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
@main.command()
|
|
315
|
+
@click.argument("file_id_or_name")
|
|
316
|
+
def info(file_id_or_name: str):
|
|
317
|
+
"""Show detailed file information."""
|
|
318
|
+
async def _info():
|
|
319
|
+
vault = TeleVault()
|
|
320
|
+
await vault.connect()
|
|
321
|
+
|
|
322
|
+
try:
|
|
323
|
+
# Find file
|
|
324
|
+
files = await vault.search(file_id_or_name)
|
|
325
|
+
if not files:
|
|
326
|
+
# Try by ID
|
|
327
|
+
index = await vault.telegram.get_index()
|
|
328
|
+
for fid, msg_id in index.files.items():
|
|
329
|
+
if fid.startswith(file_id_or_name):
|
|
330
|
+
metadata = await vault.telegram.get_metadata(msg_id)
|
|
331
|
+
files = [metadata]
|
|
332
|
+
break
|
|
333
|
+
|
|
334
|
+
if not files:
|
|
335
|
+
console.print(f"[red]File not found: {file_id_or_name}[/red]")
|
|
336
|
+
await vault.disconnect()
|
|
337
|
+
return
|
|
338
|
+
|
|
339
|
+
f = files[0]
|
|
340
|
+
|
|
341
|
+
console.print(f"[bold]{f.name}[/bold]\n")
|
|
342
|
+
console.print(f" ID: {f.id}")
|
|
343
|
+
console.print(f" Size: {format_size(f.size)}")
|
|
344
|
+
console.print(f" Hash: {f.hash}")
|
|
345
|
+
console.print(f" Chunks: {f.chunk_count}")
|
|
346
|
+
console.print(f" Encrypted: {'Yes 🔒' if f.encrypted else 'No'}")
|
|
347
|
+
console.print(f" Compressed: {'Yes' if f.compressed else 'No'}")
|
|
348
|
+
if f.compressed and f.compression_ratio:
|
|
349
|
+
console.print(f" Comp. ratio: {f.compression_ratio:.1%}")
|
|
350
|
+
if f.mime_type:
|
|
351
|
+
console.print(f" MIME type: {f.mime_type}")
|
|
352
|
+
|
|
353
|
+
from datetime import datetime
|
|
354
|
+
created = datetime.fromtimestamp(f.created_at)
|
|
355
|
+
console.print(f" Created: {created.strftime('%Y-%m-%d %H:%M')}")
|
|
356
|
+
|
|
357
|
+
if f.chunks:
|
|
358
|
+
stored = sum(c.size for c in f.chunks)
|
|
359
|
+
console.print(f" Stored size: {format_size(stored)}")
|
|
360
|
+
|
|
361
|
+
except Exception as e:
|
|
362
|
+
console.print(f"[red]Error: {e}[/red]")
|
|
363
|
+
|
|
364
|
+
await vault.disconnect()
|
|
365
|
+
|
|
366
|
+
run_async(_info())
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
@main.command()
|
|
370
|
+
@click.argument("file_id_or_name")
|
|
371
|
+
@click.option("--yes", "-y", is_flag=True, help="Skip confirmation")
|
|
372
|
+
def rm(file_id_or_name: str, yes: bool):
|
|
373
|
+
"""Delete a file from the vault."""
|
|
374
|
+
async def _rm():
|
|
375
|
+
vault = TeleVault()
|
|
376
|
+
await vault.connect()
|
|
377
|
+
|
|
378
|
+
if not yes:
|
|
379
|
+
if not click.confirm(f"Delete '{file_id_or_name}'?"):
|
|
380
|
+
console.print("[dim]Cancelled[/dim]")
|
|
381
|
+
await vault.disconnect()
|
|
382
|
+
return
|
|
383
|
+
|
|
384
|
+
deleted = await vault.delete(file_id_or_name)
|
|
385
|
+
|
|
386
|
+
if deleted:
|
|
387
|
+
console.print(f"[green]✓ Deleted: {file_id_or_name}[/green]")
|
|
388
|
+
else:
|
|
389
|
+
console.print(f"[red]✗ File not found: {file_id_or_name}[/red]")
|
|
390
|
+
|
|
391
|
+
await vault.disconnect()
|
|
392
|
+
|
|
393
|
+
run_async(_rm())
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
@main.command()
|
|
397
|
+
def status():
|
|
398
|
+
"""Show vault status."""
|
|
399
|
+
async def _status():
|
|
400
|
+
vault = TeleVault()
|
|
401
|
+
await vault.connect()
|
|
402
|
+
|
|
403
|
+
try:
|
|
404
|
+
status = await vault.get_status()
|
|
405
|
+
|
|
406
|
+
console.print("[bold]TeleVault Status[/bold]\n")
|
|
407
|
+
console.print(f" Channel: {status['channel_id']}")
|
|
408
|
+
console.print(f" Files: {status['file_count']}")
|
|
409
|
+
console.print(f" Total size: {format_size(status['total_size'])}")
|
|
410
|
+
console.print(f" Stored size: {format_size(status['stored_size'])}")
|
|
411
|
+
console.print(f" Compression ratio: {status['compression_ratio']:.1%}")
|
|
412
|
+
except Exception as e:
|
|
413
|
+
console.print(f"[red]Error: {e}[/red]")
|
|
414
|
+
console.print("\n[dim]Have you run 'televault login' and 'televault setup'?[/dim]")
|
|
415
|
+
|
|
416
|
+
await vault.disconnect()
|
|
417
|
+
|
|
418
|
+
run_async(_status())
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
@main.command()
|
|
422
|
+
def whoami():
|
|
423
|
+
"""Show current Telegram account."""
|
|
424
|
+
async def _whoami():
|
|
425
|
+
vault = TeleVault()
|
|
426
|
+
await vault.connect()
|
|
427
|
+
|
|
428
|
+
me = await vault.telegram._client.get_me()
|
|
429
|
+
|
|
430
|
+
console.print(f"[bold]{me.first_name}[/bold]", end="")
|
|
431
|
+
if me.last_name:
|
|
432
|
+
console.print(f" {me.last_name}", end="")
|
|
433
|
+
console.print()
|
|
434
|
+
|
|
435
|
+
if me.username:
|
|
436
|
+
console.print(f" @{me.username}")
|
|
437
|
+
console.print(f" ID: {me.id}")
|
|
438
|
+
|
|
439
|
+
await vault.disconnect()
|
|
440
|
+
|
|
441
|
+
run_async(_whoami())
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
if __name__ == "__main__":
|
|
445
|
+
main()
|