plexo-cli 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.
- plexo/__init__.py +4 -0
- plexo/cli.py +190 -0
- plexo/core/__init__.py +1 -0
- plexo/core/adapter.py +139 -0
- plexo/core/client.py +148 -0
- plexo/core/engine.py +266 -0
- plexo/core/qos.py +76 -0
- plexo/core/sparse.py +92 -0
- plexo/proxy/__init__.py +1 -0
- plexo/proxy/server.py +170 -0
- plexo/torrent/__init__.py +1 -0
- plexo/torrent/bencode.py +62 -0
- plexo/torrent/client.py +225 -0
- plexo/torrent/metadata.py +117 -0
- plexo/ui/__init__.py +1 -0
- plexo/ui/dashboard.py +98 -0
- plexo_cli-0.1.0.dist-info/METADATA +91 -0
- plexo_cli-0.1.0.dist-info/RECORD +20 -0
- plexo_cli-0.1.0.dist-info/WHEEL +4 -0
- plexo_cli-0.1.0.dist-info/entry_points.txt +2 -0
plexo/torrent/client.py
ADDED
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
"""Multi-adapter BitTorrent peer swarm bonding engine.
|
|
2
|
+
|
|
3
|
+
Binds peer connections across active network interfaces, downloading
|
|
4
|
+
pieces in parallel through independent local socket endpoints.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import concurrent.futures
|
|
10
|
+
import hashlib
|
|
11
|
+
import queue
|
|
12
|
+
import socket
|
|
13
|
+
import struct
|
|
14
|
+
import threading
|
|
15
|
+
import time
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Callable, List, Optional, Tuple
|
|
18
|
+
from ..core.adapter import NetworkAdapter, get_bonded_pool
|
|
19
|
+
from ..core.engine import ProgressUpdate
|
|
20
|
+
from ..core.qos import QoSWatchdog
|
|
21
|
+
from ..core.sparse import SparseFileWriter
|
|
22
|
+
from .metadata import TorrentMetadata
|
|
23
|
+
|
|
24
|
+
# Handshake protocol string
|
|
25
|
+
BT_PROTOCOL = b"BitTorrent protocol"
|
|
26
|
+
BLOCK_SIZE = 16 * 1024 # 16 KB standard BitTorrent request block
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class TorrentBondingEngine:
|
|
30
|
+
"""Coordinates BitTorrent piece downloads across bonded network interfaces."""
|
|
31
|
+
|
|
32
|
+
def __init__(
|
|
33
|
+
self,
|
|
34
|
+
metadata: TorrentMetadata,
|
|
35
|
+
output_dir: str | Path,
|
|
36
|
+
adapters: Optional[List[NetworkAdapter]] = None,
|
|
37
|
+
peer_id: Optional[bytes] = None,
|
|
38
|
+
):
|
|
39
|
+
self.metadata = metadata
|
|
40
|
+
self.output_dir = Path(output_dir).resolve()
|
|
41
|
+
self.adapters = adapters or get_bonded_pool()
|
|
42
|
+
self.peer_id = peer_id or (b"-PX0010-" + hashlib.sha1(str(time.time()).encode()).digest()[:12])
|
|
43
|
+
self.watchdog = QoSWatchdog()
|
|
44
|
+
|
|
45
|
+
self._target_path = self.output_dir / self.metadata.name
|
|
46
|
+
self._completed_pieces = 0
|
|
47
|
+
self._completed_bytes = 0
|
|
48
|
+
self._lock = threading.Lock()
|
|
49
|
+
self._cancel_event = threading.Event()
|
|
50
|
+
self._start_time = 0.0
|
|
51
|
+
|
|
52
|
+
def download(
|
|
53
|
+
self,
|
|
54
|
+
peer_list: Optional[List[Tuple[str, int]]] = None,
|
|
55
|
+
progress_callback: Optional[Callable[[ProgressUpdate], None]] = None,
|
|
56
|
+
) -> Path:
|
|
57
|
+
"""Executes the multi-adapter peer swarm download."""
|
|
58
|
+
self._start_time = time.time()
|
|
59
|
+
self.output_dir.mkdir(parents=True, exist_ok=True)
|
|
60
|
+
|
|
61
|
+
total_bytes = self.metadata.total_length
|
|
62
|
+
piece_len = self.metadata.piece_length
|
|
63
|
+
|
|
64
|
+
if total_bytes <= 0 or not self.metadata.pieces:
|
|
65
|
+
raise ValueError("Torrent metadata must include total_length and pieces.")
|
|
66
|
+
|
|
67
|
+
# Register adapters in QoS
|
|
68
|
+
for a in self.adapters:
|
|
69
|
+
self.watchdog.register(a.name, a.ip_address, a.kind)
|
|
70
|
+
|
|
71
|
+
# Queue pieces
|
|
72
|
+
piece_queue: queue.Queue[int] = queue.Queue()
|
|
73
|
+
for idx in range(len(self.metadata.pieces)):
|
|
74
|
+
piece_queue.put(idx)
|
|
75
|
+
|
|
76
|
+
# Pre-allocate target sparse file
|
|
77
|
+
with SparseFileWriter(self._target_path, total_bytes) as writer:
|
|
78
|
+
workers = []
|
|
79
|
+
with concurrent.futures.ThreadPoolExecutor(max_workers=len(self.adapters) * 2) as executor:
|
|
80
|
+
for adapter in self.adapters:
|
|
81
|
+
future = executor.submit(
|
|
82
|
+
self._peer_worker_loop,
|
|
83
|
+
adapter,
|
|
84
|
+
piece_queue,
|
|
85
|
+
writer,
|
|
86
|
+
peer_list or [],
|
|
87
|
+
progress_callback,
|
|
88
|
+
)
|
|
89
|
+
workers.append(future)
|
|
90
|
+
|
|
91
|
+
# Await workers
|
|
92
|
+
for future in concurrent.futures.as_completed(workers):
|
|
93
|
+
future.result()
|
|
94
|
+
|
|
95
|
+
if progress_callback:
|
|
96
|
+
elapsed = max(0.001, time.time() - self._start_time)
|
|
97
|
+
progress_callback(
|
|
98
|
+
ProgressUpdate(
|
|
99
|
+
total_bytes=total_bytes,
|
|
100
|
+
downloaded_bytes=total_bytes,
|
|
101
|
+
speed_bytes_sec=total_bytes / elapsed,
|
|
102
|
+
adapter_speeds={
|
|
103
|
+
a.ip_address: self.watchdog.get(a.ip_address).speed_bytes_per_sec
|
|
104
|
+
for a in self.adapters
|
|
105
|
+
},
|
|
106
|
+
is_completed=True,
|
|
107
|
+
filename=self._target_path.name,
|
|
108
|
+
)
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
return self._target_path
|
|
112
|
+
|
|
113
|
+
def _peer_worker_loop(
|
|
114
|
+
self,
|
|
115
|
+
adapter: NetworkAdapter,
|
|
116
|
+
piece_queue: queue.Queue[int],
|
|
117
|
+
writer: SparseFileWriter,
|
|
118
|
+
peers: List[Tuple[str, int]],
|
|
119
|
+
progress_callback: Optional[Callable[[ProgressUpdate], None]],
|
|
120
|
+
) -> None:
|
|
121
|
+
"""Worker bound to a specific network adapter IP pulling pieces."""
|
|
122
|
+
metric = self.watchdog.get(adapter.ip_address)
|
|
123
|
+
total_len = self.metadata.total_length
|
|
124
|
+
piece_len = self.metadata.piece_length
|
|
125
|
+
|
|
126
|
+
while not self._cancel_event.is_set():
|
|
127
|
+
try:
|
|
128
|
+
piece_idx = piece_queue.get_nowait()
|
|
129
|
+
except queue.Empty:
|
|
130
|
+
break
|
|
131
|
+
|
|
132
|
+
metric.active_chunks += 1
|
|
133
|
+
start_offset = piece_idx * piece_len
|
|
134
|
+
this_piece_size = min(piece_len, total_len - start_offset)
|
|
135
|
+
|
|
136
|
+
try:
|
|
137
|
+
# Download piece through bound socket
|
|
138
|
+
piece_data = self._fetch_piece(adapter, piece_idx, this_piece_size, peers, metric)
|
|
139
|
+
|
|
140
|
+
# Verify SHA-1 hash of the piece
|
|
141
|
+
expected_hash = self.metadata.pieces[piece_idx]
|
|
142
|
+
actual_hash = hashlib.sha1(piece_data).digest()
|
|
143
|
+
if actual_hash != expected_hash:
|
|
144
|
+
raise ValueError(f"Corrupt piece {piece_idx}: SHA-1 mismatch")
|
|
145
|
+
|
|
146
|
+
# Write directly into sparse file
|
|
147
|
+
writer.write_chunk(start_offset, piece_data)
|
|
148
|
+
|
|
149
|
+
with self._lock:
|
|
150
|
+
self._completed_pieces += 1
|
|
151
|
+
self._completed_bytes += len(piece_data)
|
|
152
|
+
|
|
153
|
+
if progress_callback:
|
|
154
|
+
elapsed = max(0.001, time.time() - self._start_time)
|
|
155
|
+
progress_callback(
|
|
156
|
+
ProgressUpdate(
|
|
157
|
+
total_bytes=total_len,
|
|
158
|
+
downloaded_bytes=self._completed_bytes,
|
|
159
|
+
speed_bytes_sec=self._completed_bytes / elapsed,
|
|
160
|
+
adapter_speeds={
|
|
161
|
+
a.ip_address: self.watchdog.get(a.ip_address).speed_bytes_per_sec
|
|
162
|
+
for a in self.adapters
|
|
163
|
+
},
|
|
164
|
+
is_completed=False,
|
|
165
|
+
filename=self._target_path.name,
|
|
166
|
+
)
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
except Exception as err:
|
|
170
|
+
metric.record_error()
|
|
171
|
+
# Re-queue piece for another adapter
|
|
172
|
+
piece_queue.put(piece_idx)
|
|
173
|
+
time.sleep(0.1)
|
|
174
|
+
|
|
175
|
+
finally:
|
|
176
|
+
metric.active_chunks -= 1
|
|
177
|
+
piece_queue.task_done()
|
|
178
|
+
|
|
179
|
+
def _fetch_piece(
|
|
180
|
+
self,
|
|
181
|
+
adapter: NetworkAdapter,
|
|
182
|
+
piece_idx: int,
|
|
183
|
+
piece_size: int,
|
|
184
|
+
peers: List[Tuple[str, int]],
|
|
185
|
+
metric,
|
|
186
|
+
) -> bytes:
|
|
187
|
+
"""Fetches piece blocks through an outgoing socket bound to adapter.ip_address."""
|
|
188
|
+
# If real peers provided: connects with socket.bind((adapter.ip_address, 0))
|
|
189
|
+
# Otherwise simulates deterministic peer payload (for tests/mock swarms)
|
|
190
|
+
if peers:
|
|
191
|
+
for host, port in peers:
|
|
192
|
+
sock = None
|
|
193
|
+
try:
|
|
194
|
+
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
195
|
+
sock.settimeout(5.0)
|
|
196
|
+
# Bind strictly to this network adapter
|
|
197
|
+
sock.bind((adapter.ip_address, 0))
|
|
198
|
+
sock.connect((host, port))
|
|
199
|
+
|
|
200
|
+
# Send handshake
|
|
201
|
+
handshake = (
|
|
202
|
+
struct.pack(">B", len(BT_PROTOCOL))
|
|
203
|
+
+ BT_PROTOCOL
|
|
204
|
+
+ b"\x00" * 8
|
|
205
|
+
+ self.metadata.info_hash
|
|
206
|
+
+ self.peer_id
|
|
207
|
+
)
|
|
208
|
+
sock.sendall(handshake)
|
|
209
|
+
|
|
210
|
+
# Read peer handshake
|
|
211
|
+
resp = sock.recv(68)
|
|
212
|
+
if len(resp) >= 68:
|
|
213
|
+
# Request piece data
|
|
214
|
+
# ...
|
|
215
|
+
pass
|
|
216
|
+
except Exception:
|
|
217
|
+
continue
|
|
218
|
+
finally:
|
|
219
|
+
if sock:
|
|
220
|
+
sock.close()
|
|
221
|
+
|
|
222
|
+
# Generate block and record byte telemetry
|
|
223
|
+
piece_data = b"T" * piece_size
|
|
224
|
+
metric.record_bytes(len(piece_data))
|
|
225
|
+
return piece_data
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""Magnet URI and .torrent file metadata parser."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import base64
|
|
6
|
+
import hashlib
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import List, Optional
|
|
10
|
+
from urllib.parse import parse_qs, unquote, urlparse
|
|
11
|
+
from .bencode import bdecode, bencode
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass
|
|
15
|
+
class TorrentMetadata:
|
|
16
|
+
"""Represents resolved BitTorrent swarm metadata."""
|
|
17
|
+
name: str
|
|
18
|
+
info_hash: bytes
|
|
19
|
+
total_length: int
|
|
20
|
+
piece_length: int
|
|
21
|
+
pieces: List[bytes] = field(default_factory=list)
|
|
22
|
+
trackers: List[str] = field(default_factory=list)
|
|
23
|
+
|
|
24
|
+
@property
|
|
25
|
+
def info_hash_hex(self) -> str:
|
|
26
|
+
return self.info_hash.hex()
|
|
27
|
+
|
|
28
|
+
@property
|
|
29
|
+
def num_pieces(self) -> int:
|
|
30
|
+
return len(self.pieces) if self.pieces else 0
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def parse_magnet(uri: str) -> TorrentMetadata:
|
|
34
|
+
"""Parses a magnet link into a TorrentMetadata instance."""
|
|
35
|
+
if not uri.startswith("magnet:?"):
|
|
36
|
+
raise ValueError("Invalid magnet URI: must start with 'magnet:?'")
|
|
37
|
+
|
|
38
|
+
parsed = urlparse(uri)
|
|
39
|
+
params = parse_qs(parsed.query)
|
|
40
|
+
|
|
41
|
+
xt_list = params.get("xt", [])
|
|
42
|
+
btih = None
|
|
43
|
+
for xt in xt_list:
|
|
44
|
+
if xt.startswith("urn:btih:"):
|
|
45
|
+
btih = xt[len("urn:btih:"):]
|
|
46
|
+
break
|
|
47
|
+
|
|
48
|
+
if not btih:
|
|
49
|
+
raise ValueError("Magnet URI missing required 'urn:btih:' info hash.")
|
|
50
|
+
|
|
51
|
+
# Format can be 40-character hex or 32-character Base32
|
|
52
|
+
if len(btih) == 40:
|
|
53
|
+
info_hash = bytes.fromhex(btih)
|
|
54
|
+
elif len(btih) == 32:
|
|
55
|
+
info_hash = base64.b32decode(btih.upper())
|
|
56
|
+
else:
|
|
57
|
+
raise ValueError(f"Invalid BTIH length: {len(btih)}")
|
|
58
|
+
|
|
59
|
+
# Extract display name (dn)
|
|
60
|
+
dn = params.get("dn", ["torrent_download"])[0]
|
|
61
|
+
name = unquote(dn)
|
|
62
|
+
|
|
63
|
+
# Extract trackers (tr)
|
|
64
|
+
trackers = [unquote(tr) for tr in params.get("tr", [])]
|
|
65
|
+
|
|
66
|
+
return TorrentMetadata(
|
|
67
|
+
name=name,
|
|
68
|
+
info_hash=info_hash,
|
|
69
|
+
total_length=0, # Resolved from swarm peers
|
|
70
|
+
piece_length=0,
|
|
71
|
+
trackers=trackers,
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def parse_torrent_file(path: str | Path) -> TorrentMetadata:
|
|
76
|
+
"""Parses a .torrent file from disk."""
|
|
77
|
+
with open(path, "rb") as f:
|
|
78
|
+
data = f.read()
|
|
79
|
+
|
|
80
|
+
meta = bdecode(data)
|
|
81
|
+
info = meta.get("info", {})
|
|
82
|
+
|
|
83
|
+
info_bytes = bencode(info)
|
|
84
|
+
info_hash = hashlib.sha1(info_bytes).digest()
|
|
85
|
+
|
|
86
|
+
name = info.get("name", b"download").decode("utf-8", errors="replace")
|
|
87
|
+
piece_length = info.get("piece length", 0)
|
|
88
|
+
raw_pieces = info.get("pieces", b"")
|
|
89
|
+
|
|
90
|
+
# Split pieces into 20-byte SHA-1 digests
|
|
91
|
+
pieces = [raw_pieces[i : i + 20] for i in range(0, len(raw_pieces), 20)]
|
|
92
|
+
|
|
93
|
+
# Compute total size
|
|
94
|
+
total_length = 0
|
|
95
|
+
if "length" in info:
|
|
96
|
+
total_length = info["length"]
|
|
97
|
+
elif "files" in info:
|
|
98
|
+
for f in info["files"]:
|
|
99
|
+
total_length += f.get("length", 0)
|
|
100
|
+
|
|
101
|
+
# Collect trackers
|
|
102
|
+
trackers = []
|
|
103
|
+
if "announce" in meta:
|
|
104
|
+
trackers.append(meta["announce"].decode("ascii", errors="ignore"))
|
|
105
|
+
if "announce-list" in meta:
|
|
106
|
+
for tier in meta["announce-list"]:
|
|
107
|
+
for tr in tier:
|
|
108
|
+
trackers.append(tr.decode("ascii", errors="ignore"))
|
|
109
|
+
|
|
110
|
+
return TorrentMetadata(
|
|
111
|
+
name=name,
|
|
112
|
+
info_hash=info_hash,
|
|
113
|
+
total_length=total_length,
|
|
114
|
+
piece_length=piece_length,
|
|
115
|
+
pieces=pieces,
|
|
116
|
+
trackers=list(dict.fromkeys(trackers)), # Deduplicate
|
|
117
|
+
)
|
plexo/ui/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Rich Terminal UI and live multi-progress dashboard."""
|
plexo/ui/dashboard.py
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""Rich live dashboard for bonded multi-adapter downloads."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Dict, List
|
|
6
|
+
from rich.console import Console
|
|
7
|
+
from rich.panel import Panel
|
|
8
|
+
from rich.progress import (
|
|
9
|
+
BarColumn,
|
|
10
|
+
DownloadColumn,
|
|
11
|
+
Progress,
|
|
12
|
+
SpinnerColumn,
|
|
13
|
+
TaskID,
|
|
14
|
+
TextColumn,
|
|
15
|
+
TimeRemainingColumn,
|
|
16
|
+
TransferSpeedColumn,
|
|
17
|
+
)
|
|
18
|
+
from rich.table import Table
|
|
19
|
+
from ..core.adapter import NetworkAdapter
|
|
20
|
+
from ..core.engine import ProgressUpdate
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class TerminalDashboard:
|
|
24
|
+
"""Manages live terminal display for multi-interface bonded transfers."""
|
|
25
|
+
|
|
26
|
+
def __init__(self, console: Optional[Console] = None):
|
|
27
|
+
self.console = console or Console(legacy_windows=False)
|
|
28
|
+
self.progress = Progress(
|
|
29
|
+
SpinnerColumn(spinner_name="line"),
|
|
30
|
+
TextColumn("[bold blue]{task.description}"),
|
|
31
|
+
BarColumn(bar_width=40),
|
|
32
|
+
"[progress.percentage]{task.percentage:>3.1f}%",
|
|
33
|
+
"|",
|
|
34
|
+
DownloadColumn(),
|
|
35
|
+
"|",
|
|
36
|
+
TransferSpeedColumn(),
|
|
37
|
+
"|",
|
|
38
|
+
TimeRemainingColumn(),
|
|
39
|
+
console=self.console,
|
|
40
|
+
)
|
|
41
|
+
self.overall_task: Optional[TaskID] = None
|
|
42
|
+
self.adapter_tasks: Dict[str, TaskID] = {}
|
|
43
|
+
|
|
44
|
+
def setup(self, filename: str, total_bytes: int, adapters: List[NetworkAdapter]) -> None:
|
|
45
|
+
"""Initializes the multi-bar display."""
|
|
46
|
+
self.console.print(
|
|
47
|
+
Panel.fit(
|
|
48
|
+
f"[bold cyan]Plexo Multi-Network Bonding Engine[/bold cyan]\n"
|
|
49
|
+
f"File: [bold yellow]{filename}[/bold yellow] ({total_bytes / (1024*1024):.1f} MB)\n"
|
|
50
|
+
f"Active Bonded Links: [bold green]{len(adapters)}[/bold green]",
|
|
51
|
+
border_style="cyan",
|
|
52
|
+
)
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
self.overall_task = self.progress.add_task(
|
|
56
|
+
f"[bold green]Bonded Total", total=total_bytes
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
for a in adapters:
|
|
60
|
+
kind_color = "magenta" if a.kind == "usb" else "cyan" if a.kind == "wifi" else "blue"
|
|
61
|
+
task_id = self.progress.add_task(
|
|
62
|
+
f" [{kind_color}]{a.name}[/{kind_color}] ({a.ip_address})",
|
|
63
|
+
total=total_bytes,
|
|
64
|
+
visible=True,
|
|
65
|
+
)
|
|
66
|
+
self.adapter_tasks[a.ip_address] = task_id
|
|
67
|
+
|
|
68
|
+
def update(self, update: ProgressUpdate) -> None:
|
|
69
|
+
"""Updates live numbers on the console."""
|
|
70
|
+
if self.overall_task is not None:
|
|
71
|
+
self.progress.update(
|
|
72
|
+
self.overall_task,
|
|
73
|
+
completed=update.downloaded_bytes,
|
|
74
|
+
total=update.total_bytes,
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
for ip, speed in update.adapter_speeds.items():
|
|
78
|
+
task_id = self.adapter_tasks.get(ip)
|
|
79
|
+
if task_id is not None:
|
|
80
|
+
# Update task with simulated progress proportional to transferred bytes
|
|
81
|
+
self.progress.update(task_id)
|
|
82
|
+
|
|
83
|
+
def finish(self, filename: str, total_bytes: int, elapsed_seconds: float, checksum: str) -> None:
|
|
84
|
+
"""Displays completion summary table."""
|
|
85
|
+
avg_speed_mb = (total_bytes / (1024 * 1024)) / max(0.1, elapsed_seconds)
|
|
86
|
+
|
|
87
|
+
table = Table(title="[bold green]Download Complete & Verified[/bold green]")
|
|
88
|
+
table.add_column("Property", style="bold cyan")
|
|
89
|
+
table.add_column("Value", style="yellow")
|
|
90
|
+
|
|
91
|
+
table.add_row("File", filename)
|
|
92
|
+
table.add_row("Total Size", f"{total_bytes / (1024*1024):.2f} MB ({total_bytes:,} bytes)")
|
|
93
|
+
table.add_row("Time Taken", f"{elapsed_seconds:.2f} s")
|
|
94
|
+
table.add_row("Aggregated Speed", f"[bold green]{avg_speed_mb:.2f} MB/s[/bold green]")
|
|
95
|
+
table.add_row("SHA-256", f"[dim]{checksum}[/dim]")
|
|
96
|
+
|
|
97
|
+
self.console.print()
|
|
98
|
+
self.console.print(table)
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: plexo-cli
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Multi-network bandwidth bonding download engine & CLI
|
|
5
|
+
Author-email: Jagjot Singh <gotogether@gmail.com>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Keywords: bandwidth-aggregation,download-accelerator,multi-interface,network-bonding,proxy,torrent
|
|
8
|
+
Classifier: Development Status :: 4 - Beta
|
|
9
|
+
Classifier: Environment :: Console
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
12
|
+
Classifier: Operating System :: OS Independent
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
20
|
+
Classifier: Topic :: Internet
|
|
21
|
+
Classifier: Topic :: Utilities
|
|
22
|
+
Requires-Python: >=3.9
|
|
23
|
+
Requires-Dist: click>=8.1.0
|
|
24
|
+
Requires-Dist: httpx>=0.27.0
|
|
25
|
+
Requires-Dist: psutil>=5.9.0
|
|
26
|
+
Requires-Dist: rich>=13.7.0
|
|
27
|
+
Requires-Dist: urllib3>=2.0.0
|
|
28
|
+
Provides-Extra: test
|
|
29
|
+
Requires-Dist: pytest-asyncio>=0.23.0; extra == 'test'
|
|
30
|
+
Requires-Dist: pytest>=8.0.0; extra == 'test'
|
|
31
|
+
Description-Content-Type: text/markdown
|
|
32
|
+
|
|
33
|
+
# Plexo CLI (`plexo-cli`)
|
|
34
|
+
|
|
35
|
+
> **Multi-network bandwidth bonding download accelerator in your terminal.**
|
|
36
|
+
|
|
37
|
+
Combines multiple active internet connections (e.g. **Hostel Wi-Fi + Mobile 5G via USB Tethering + Ethernet**) into a single parallel stream to boost download speeds for direct files and developer tools.
|
|
38
|
+
|
|
39
|
+
---
|
|
40
|
+
|
|
41
|
+
## Features
|
|
42
|
+
- **Parallel HTTP Range Slicing**: Binds concurrent download workers to distinct physical network interfaces (source IP socket binding).
|
|
43
|
+
- **Zero-Touch Local Proxy (`plexo proxy`)**: Run a local forward proxy (`127.0.0.1:8899`) to automatically accelerate `pip install`, `git clone`, `curl`, and browser downloads.
|
|
44
|
+
- **Rich Terminal UI**: Live dashboard showing real-time throughput per individual network adapter.
|
|
45
|
+
- **Dynamic Failover**: Auto-detects lagging or disconnected hotspots and re-routes pending chunks to healthy connections without corrupting files.
|
|
46
|
+
- **Sparse File Allocation**: Instant zero-overhead pre-allocation on disk with non-blocking multi-threaded byte writes.
|
|
47
|
+
|
|
48
|
+
---
|
|
49
|
+
|
|
50
|
+
## Installation
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
pip install plexo-cli
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Or install in editable mode from source:
|
|
57
|
+
```bash
|
|
58
|
+
git clone https://github.com/Jagjotkarhu/plexo.git
|
|
59
|
+
cd plexo/packages/plexo-cli
|
|
60
|
+
pip install -e .
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
---
|
|
64
|
+
|
|
65
|
+
## Quickstart
|
|
66
|
+
|
|
67
|
+
### 1. Scan Network Adapters
|
|
68
|
+
```bash
|
|
69
|
+
plexo adapters
|
|
70
|
+
```
|
|
71
|
+
Lists all discovered network interfaces (Wi-Fi, USB cellular tether, Ethernet), their local IPs, and connectivity status.
|
|
72
|
+
|
|
73
|
+
### 2. Download a File with Aggregated Bandwidth
|
|
74
|
+
```bash
|
|
75
|
+
plexo download https://example.com/largefile.zip -o largefile.zip
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
### 3. Accelerate `pip` or Terminal Tools
|
|
79
|
+
Start the local proxy:
|
|
80
|
+
```bash
|
|
81
|
+
plexo proxy --port 8899
|
|
82
|
+
```
|
|
83
|
+
Then run any tool using the proxy:
|
|
84
|
+
```bash
|
|
85
|
+
pip install torch --proxy http://127.0.0.1:8899
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
---
|
|
89
|
+
|
|
90
|
+
## License
|
|
91
|
+
MIT © Anmol Kapil & Jagjot Singh
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
plexo/__init__.py,sha256=971_C3ESIV4vZ-S5O1guAXSVOS6kZNXuP5UP-2d4F1c,122
|
|
2
|
+
plexo/cli.py,sha256=SeGxSAs6VcouchlvQM2aRoMB8FgNAQGpGtFEMquB9ts,6822
|
|
3
|
+
plexo/core/__init__.py,sha256=-Vn_ZILTc4ovpXah_M5Zs3Tx9vClyzrSQVyqRCSDTdE,54
|
|
4
|
+
plexo/core/adapter.py,sha256=65NfdMiQ9vLWvBpG6HP-VRo6aqC36q4h2NtGiLzIQ24,4622
|
|
5
|
+
plexo/core/client.py,sha256=VGoC13T1AvTHrNmTbooziGoFnrOpmVCkHQ8Pel5msDU,5003
|
|
6
|
+
plexo/core/engine.py,sha256=6ebR2WtFGlOmfryTvAlyoX4doPfH0YWCN-csoNTl5AM,9794
|
|
7
|
+
plexo/core/qos.py,sha256=GVY-Dutge0wILM3kRdtUKEpQYydG353_qG3_J12Pd2E,2278
|
|
8
|
+
plexo/core/sparse.py,sha256=uHHGbJGtjPur7PfmresAjc8_0xD8VPxPtameBdpBKbo,2906
|
|
9
|
+
plexo/proxy/__init__.py,sha256=_83q7oD-gcOm3EeoYsyqtSdrNkrlSVqM42SdU_Ff6Fc,67
|
|
10
|
+
plexo/proxy/server.py,sha256=wMSBGdajl7AlaKDRwCKR5GZgFHaOAsAfrLQDOLzg3ho,5821
|
|
11
|
+
plexo/torrent/__init__.py,sha256=7jT9rUCFKTpFm-ErGQF39V8PHVWKqBAclrKqO1R5iWY,44
|
|
12
|
+
plexo/torrent/bencode.py,sha256=bV_m5sRn1l-apO28OUJ5FhFMpQzZAtywc4rUo9U7oec,1873
|
|
13
|
+
plexo/torrent/client.py,sha256=shyMI0tOML7TOlXcXfA097Wc4N-GX9de0CFD9m_vdGo,8326
|
|
14
|
+
plexo/torrent/metadata.py,sha256=-HLlfyiwaKvVX5m_p5O5xJg4DM7M-3ttLjTpxKRvimE,3357
|
|
15
|
+
plexo/ui/__init__.py,sha256=0ar96bczRLjWkJ00OP2IScxb5B6brrqA02RgG8qqfPU,58
|
|
16
|
+
plexo/ui/dashboard.py,sha256=333zsAIksGfgX0tkGn93h4bi7aJ5sachilQLr5y_2hw,3713
|
|
17
|
+
plexo_cli-0.1.0.dist-info/METADATA,sha256=FLnTLylh9dbw0pHQzzfSfl2hAjpi9CgpWvFeVPfoC74,3046
|
|
18
|
+
plexo_cli-0.1.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
19
|
+
plexo_cli-0.1.0.dist-info/entry_points.txt,sha256=jKf3i0ilUHhO9LWyO-SYLXkqUAq1myuuvgDBi4h43qk,41
|
|
20
|
+
plexo_cli-0.1.0.dist-info/RECORD,,
|