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/__init__.py
ADDED
plexo/cli.py
ADDED
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
"""Command Line Interface for Plexo.
|
|
2
|
+
|
|
3
|
+
Exposes subcommands: download, adapters, proxy, info.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import sys
|
|
9
|
+
import time
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Optional
|
|
12
|
+
|
|
13
|
+
# Ensure UTF-8 output on Windows consoles
|
|
14
|
+
if sys.platform == "win32":
|
|
15
|
+
try:
|
|
16
|
+
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
|
|
17
|
+
sys.stderr.reconfigure(encoding="utf-8", errors="replace")
|
|
18
|
+
except Exception:
|
|
19
|
+
pass
|
|
20
|
+
|
|
21
|
+
import click
|
|
22
|
+
from rich.console import Console
|
|
23
|
+
from rich.panel import Panel
|
|
24
|
+
from rich.table import Table
|
|
25
|
+
from . import __version__
|
|
26
|
+
from .core.adapter import discover_adapters, get_bonded_pool
|
|
27
|
+
from .core.engine import BondingDownloadEngine, ProgressUpdate
|
|
28
|
+
from .proxy.server import run_proxy
|
|
29
|
+
from .ui.dashboard import TerminalDashboard
|
|
30
|
+
|
|
31
|
+
console = Console()
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@click.group()
|
|
35
|
+
@click.version_option(version=__version__, prog_name="plexo")
|
|
36
|
+
def main():
|
|
37
|
+
"""Plexo: Multi-Network Bandwidth Bonding Download Engine."""
|
|
38
|
+
pass
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@main.command(name="adapters")
|
|
42
|
+
@click.option("--all", "show_all", is_flag=True, help="Show all adapters including offline/internal.")
|
|
43
|
+
def list_adapters(show_all: bool):
|
|
44
|
+
"""Scan and benchmark active network adapters."""
|
|
45
|
+
console.print("\n[bold cyan]Scanning Network Interfaces...[/bold cyan]")
|
|
46
|
+
adapters = discover_adapters(verify_online=True)
|
|
47
|
+
|
|
48
|
+
table = Table(title="[bold green]Network Interfaces[/bold green]")
|
|
49
|
+
table.add_column("Interface Name", style="bold white")
|
|
50
|
+
table.add_column("Type", style="magenta")
|
|
51
|
+
table.add_column("IPv4 Address", style="cyan")
|
|
52
|
+
table.add_column("Link Speed", style="yellow")
|
|
53
|
+
table.add_column("Internet Route", style="bold")
|
|
54
|
+
|
|
55
|
+
display_list = adapters if show_all else [a for a in adapters if a.is_online]
|
|
56
|
+
|
|
57
|
+
for a in display_list:
|
|
58
|
+
status_text = "[bold green]ONLINE[/bold green]" if a.is_online else "[dim red]OFFLINE[/dim red]"
|
|
59
|
+
speed_str = f"{a.speed_mbps} Mbps" if a.speed_mbps > 0 else "Unknown"
|
|
60
|
+
table.add_row(a.name, a.kind.upper(), a.ip_address, speed_str, status_text)
|
|
61
|
+
|
|
62
|
+
console.print(table)
|
|
63
|
+
online_count = sum(1 for a in adapters if a.is_online)
|
|
64
|
+
|
|
65
|
+
if online_count >= 2:
|
|
66
|
+
console.print(
|
|
67
|
+
f"\n[bold green][OK] Ready for bonding:[/bold green] [yellow]{online_count}[/yellow] independent connections detected.\n"
|
|
68
|
+
)
|
|
69
|
+
elif online_count == 1:
|
|
70
|
+
console.print(
|
|
71
|
+
"\n[yellow][INFO] 1 connection active.[/yellow] Plug in Phone 5G USB tethering or Ethernet to enable multi-network bonding.\n"
|
|
72
|
+
)
|
|
73
|
+
else:
|
|
74
|
+
console.print("\n[bold red][X] No online connections found.[/bold red]\n")
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@main.command(name="download")
|
|
78
|
+
@click.argument("url")
|
|
79
|
+
@click.option("-o", "--output", "output_path", type=click.Path(), default=None, help="Output destination path.")
|
|
80
|
+
@click.option("--chunk-size", default=8, type=int, help="Chunk size in MB (default: 8 MB).")
|
|
81
|
+
@click.option("--workers", default=2, type=int, help="Workers per network adapter (default: 2).")
|
|
82
|
+
def download_file(url: str, output_path: Optional[str], chunk_size: int, workers: int):
|
|
83
|
+
"""Download a file using bonded multi-network connections."""
|
|
84
|
+
adapters = get_bonded_pool()
|
|
85
|
+
if not adapters:
|
|
86
|
+
console.print("[bold red]Error: No active online network adapters detected.[/bold red]")
|
|
87
|
+
raise click.Abort()
|
|
88
|
+
|
|
89
|
+
dashboard = TerminalDashboard(console=console)
|
|
90
|
+
chunk_bytes = chunk_size * 1024 * 1024
|
|
91
|
+
|
|
92
|
+
engine = BondingDownloadEngine(
|
|
93
|
+
url=url,
|
|
94
|
+
output_path=output_path,
|
|
95
|
+
adapters=adapters,
|
|
96
|
+
chunk_size=chunk_bytes,
|
|
97
|
+
workers_per_adapter=workers,
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
probe = engine._prepare()
|
|
101
|
+
dashboard.setup(probe.filename, probe.total_bytes, adapters)
|
|
102
|
+
|
|
103
|
+
start_time = time.time()
|
|
104
|
+
with dashboard.progress:
|
|
105
|
+
final_path = engine.execute(progress_callback=dashboard.update)
|
|
106
|
+
|
|
107
|
+
elapsed = time.time() - start_time
|
|
108
|
+
total_bytes = final_path.stat().st_size
|
|
109
|
+
|
|
110
|
+
# Calculate checksum
|
|
111
|
+
from .core.sparse import SparseFileWriter
|
|
112
|
+
with SparseFileWriter(final_path, total_bytes) as writer:
|
|
113
|
+
checksum = writer.calculate_checksum()
|
|
114
|
+
|
|
115
|
+
dashboard.finish(final_path.name, total_bytes, elapsed, checksum)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
@main.command(name="proxy")
|
|
119
|
+
@click.option("--host", default="127.0.0.1", help="Proxy listening host (default: 127.0.0.1).")
|
|
120
|
+
@click.option("--port", default=8899, type=int, help="Proxy listening port (default: 8899).")
|
|
121
|
+
def start_proxy(host: str, port: int):
|
|
122
|
+
"""Launch local acceleration proxy for pip, git, and curl."""
|
|
123
|
+
run_proxy(host=host, port=port)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
@main.command(name="torrent")
|
|
127
|
+
@click.argument("source")
|
|
128
|
+
@click.option("-o", "--output-dir", default=".", help="Directory to save downloaded files.")
|
|
129
|
+
def download_torrent(source: str, output_dir: str):
|
|
130
|
+
"""Download a torrent or magnet link across bonded network adapters."""
|
|
131
|
+
from .torrent.metadata import parse_magnet, parse_torrent_file
|
|
132
|
+
from .torrent.client import TorrentBondingEngine
|
|
133
|
+
|
|
134
|
+
adapters = get_bonded_pool()
|
|
135
|
+
if not adapters:
|
|
136
|
+
console.print("[bold red]Error: No active online network adapters detected.[/bold red]")
|
|
137
|
+
raise click.Abort()
|
|
138
|
+
|
|
139
|
+
if source.startswith("magnet:?"):
|
|
140
|
+
meta = parse_magnet(source)
|
|
141
|
+
else:
|
|
142
|
+
meta = parse_torrent_file(source)
|
|
143
|
+
|
|
144
|
+
console.print(
|
|
145
|
+
Panel.fit(
|
|
146
|
+
f"[bold cyan]Plexo Torrent Swarm Engine[/bold cyan]\n"
|
|
147
|
+
f"Name: [bold yellow]{meta.name}[/bold yellow]\n"
|
|
148
|
+
f"Info Hash: [dim]{meta.info_hash_hex}[/dim]\n"
|
|
149
|
+
f"Active Network Links: [bold green]{len(adapters)}[/bold green]",
|
|
150
|
+
border_style="cyan",
|
|
151
|
+
)
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
dashboard = TerminalDashboard(console=console)
|
|
155
|
+
dashboard.setup(meta.name, meta.total_length, adapters)
|
|
156
|
+
|
|
157
|
+
engine = TorrentBondingEngine(metadata=meta, output_dir=output_dir, adapters=adapters)
|
|
158
|
+
|
|
159
|
+
start_time = time.time()
|
|
160
|
+
with dashboard.progress:
|
|
161
|
+
final_path = engine.download(progress_callback=dashboard.update)
|
|
162
|
+
|
|
163
|
+
elapsed = time.time() - start_time
|
|
164
|
+
total_bytes = final_path.stat().st_size
|
|
165
|
+
|
|
166
|
+
from .core.sparse import SparseFileWriter
|
|
167
|
+
with SparseFileWriter(final_path, total_bytes) as writer:
|
|
168
|
+
checksum = writer.calculate_checksum()
|
|
169
|
+
|
|
170
|
+
dashboard.finish(final_path.name, total_bytes, elapsed, checksum)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
@main.command(name="info")
|
|
174
|
+
def show_info():
|
|
175
|
+
"""Display Plexo environment and bonding capabilities."""
|
|
176
|
+
adapters = get_bonded_pool()
|
|
177
|
+
console.print(
|
|
178
|
+
Panel.fit(
|
|
179
|
+
f"[bold cyan]Plexo CLI v{__version__}[/bold cyan]\n"
|
|
180
|
+
f"Bonding Engine: [bold green]Active[/bold green]\n"
|
|
181
|
+
f"Verified Online Adapters: [bold yellow]{len(adapters)}[/bold yellow]\n"
|
|
182
|
+
f"Default Proxy Port: [bold magenta]8899[/bold magenta]",
|
|
183
|
+
border_style="cyan",
|
|
184
|
+
title="System Capabilities",
|
|
185
|
+
)
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
if __name__ == "__main__":
|
|
190
|
+
main()
|
plexo/core/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Core multi-adapter bonding and download engine."""
|
plexo/core/adapter.py
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
"""Network interface discovery and binding module.
|
|
2
|
+
|
|
3
|
+
Detects active network adapters (Wi-Fi, USB cellular tether, Ethernet),
|
|
4
|
+
filters internal/virtual networks, and verifies outward internet reachability.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import ipaddress
|
|
10
|
+
import platform
|
|
11
|
+
import socket
|
|
12
|
+
import subprocess
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from typing import List, Optional
|
|
15
|
+
import psutil
|
|
16
|
+
import urllib3
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class NetworkAdapter:
|
|
21
|
+
"""Represents a discovered physical or virtual network interface."""
|
|
22
|
+
name: str
|
|
23
|
+
ip_address: str
|
|
24
|
+
netmask: str
|
|
25
|
+
kind: str # "wifi", "usb", "ethernet", "other"
|
|
26
|
+
is_up: bool
|
|
27
|
+
speed_mbps: int = 0
|
|
28
|
+
gateway: Optional[str] = None
|
|
29
|
+
is_online: bool = False
|
|
30
|
+
|
|
31
|
+
@property
|
|
32
|
+
def display_name(self) -> str:
|
|
33
|
+
kind_label = self.kind.upper()
|
|
34
|
+
return f"{self.name} [{kind_label}] ({self.ip_address})"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def classify_interface(name: str) -> str:
|
|
38
|
+
"""Classifies interface type based on OS naming conventions."""
|
|
39
|
+
name_lower = name.lower()
|
|
40
|
+
if any(k in name_lower for k in ("wi-fi", "wifi", "wireless", "wlan", "802.11")):
|
|
41
|
+
return "wifi"
|
|
42
|
+
if any(k in name_lower for k in ("rndis", "tether", "remote ndis", "apple mobile", "usb", "enx")):
|
|
43
|
+
return "usb"
|
|
44
|
+
if any(k in name_lower for k in ("ethernet", "lan", "eth", "en0", "en1")):
|
|
45
|
+
return "ethernet"
|
|
46
|
+
return "other"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def is_usable_ip(ip_str: str) -> bool:
|
|
50
|
+
"""Checks if an IPv4 address is routable and not loopback/link-local."""
|
|
51
|
+
try:
|
|
52
|
+
ip = ipaddress.IPv4Address(ip_str)
|
|
53
|
+
if ip.is_loopback:
|
|
54
|
+
return False
|
|
55
|
+
if ip.is_link_local: # 169.254.x.x
|
|
56
|
+
return False
|
|
57
|
+
if ip.is_unspecified: # 0.0.0.0
|
|
58
|
+
return False
|
|
59
|
+
return True
|
|
60
|
+
except (ValueError, ipaddress.AddressValueError):
|
|
61
|
+
return False
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def test_adapter_connectivity(ip_address: str, timeout: float = 2.0) -> bool:
|
|
65
|
+
"""Verifies that an outgoing connection bound to this source IP can reach the internet."""
|
|
66
|
+
test_hosts = [("1.1.1.1", 53), ("8.8.8.8", 53)]
|
|
67
|
+
for host, port in test_hosts:
|
|
68
|
+
sock = None
|
|
69
|
+
try:
|
|
70
|
+
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
71
|
+
sock.settimeout(timeout)
|
|
72
|
+
# Bind specifically to this adapter's source IP
|
|
73
|
+
sock.bind((ip_address, 0))
|
|
74
|
+
sock.connect((host, port))
|
|
75
|
+
return True
|
|
76
|
+
except (socket.error, OSError):
|
|
77
|
+
continue
|
|
78
|
+
finally:
|
|
79
|
+
if sock:
|
|
80
|
+
sock.close()
|
|
81
|
+
return False
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def discover_adapters(verify_online: bool = True) -> List[NetworkAdapter]:
|
|
85
|
+
"""Discovers all active, routable network adapters."""
|
|
86
|
+
adapters: List[NetworkAdapter] = []
|
|
87
|
+
seen_ips = set()
|
|
88
|
+
|
|
89
|
+
if_addrs = psutil.net_if_addrs()
|
|
90
|
+
if_stats = psutil.net_if_stats()
|
|
91
|
+
|
|
92
|
+
# Exclude common internal virtual switches unless they provide genuine routes
|
|
93
|
+
virtual_keywords = ("vEthernet", "Hyper-V", "VirtualBox", "VMware", "Loopback", "WSL")
|
|
94
|
+
|
|
95
|
+
for iface_name, addrs in if_addrs.items():
|
|
96
|
+
if any(v.lower() in iface_name.lower() for v in virtual_keywords):
|
|
97
|
+
continue
|
|
98
|
+
|
|
99
|
+
stats = if_stats.get(iface_name)
|
|
100
|
+
is_up = stats.isup if stats else True
|
|
101
|
+
if not is_up:
|
|
102
|
+
continue
|
|
103
|
+
|
|
104
|
+
for addr in addrs:
|
|
105
|
+
# We focus on IPv4 for multi-adapter bonding
|
|
106
|
+
if addr.family == socket.AF_INET:
|
|
107
|
+
ip = addr.address
|
|
108
|
+
if ip in seen_ips or not is_usable_ip(ip):
|
|
109
|
+
continue
|
|
110
|
+
|
|
111
|
+
seen_ips.add(ip)
|
|
112
|
+
kind = classify_interface(iface_name)
|
|
113
|
+
speed = stats.speed if stats else 0
|
|
114
|
+
|
|
115
|
+
online = False
|
|
116
|
+
if verify_online:
|
|
117
|
+
online = test_adapter_connectivity(ip)
|
|
118
|
+
|
|
119
|
+
adapter = NetworkAdapter(
|
|
120
|
+
name=iface_name,
|
|
121
|
+
ip_address=ip,
|
|
122
|
+
netmask=addr.netmask or "255.255.255.0",
|
|
123
|
+
kind=kind,
|
|
124
|
+
is_up=is_up,
|
|
125
|
+
speed_mbps=speed,
|
|
126
|
+
is_online=online if verify_online else is_up,
|
|
127
|
+
)
|
|
128
|
+
adapters.append(adapter)
|
|
129
|
+
|
|
130
|
+
# Sort so online adapters appear first, prioritized by speed/type
|
|
131
|
+
adapters.sort(key=lambda a: (not a.is_online, a.kind == "other", a.name))
|
|
132
|
+
return adapters
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def get_bonded_pool(min_adapters: int = 1) -> List[NetworkAdapter]:
|
|
136
|
+
"""Returns all verified online adapters ready for bonding."""
|
|
137
|
+
adapters = discover_adapters(verify_online=True)
|
|
138
|
+
online = [a for a in adapters if a.is_online]
|
|
139
|
+
return online if online else adapters
|
plexo/core/client.py
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
"""HTTP probe and socket-bound range request client.
|
|
2
|
+
|
|
3
|
+
Inspects remote servers for Range support and executes chunk transfers
|
|
4
|
+
strictly bound to designated local adapter IP addresses.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import os
|
|
10
|
+
import re
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from typing import Callable, Generator, Optional
|
|
13
|
+
from urllib.parse import unquote, urlparse
|
|
14
|
+
import urllib3
|
|
15
|
+
from urllib3.util import parse_url
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass
|
|
19
|
+
class ProbeResult:
|
|
20
|
+
"""Metadata retrieved from probing the target URL."""
|
|
21
|
+
url: str
|
|
22
|
+
total_bytes: int
|
|
23
|
+
accepts_ranges: bool
|
|
24
|
+
filename: str
|
|
25
|
+
content_type: str
|
|
26
|
+
etag: Optional[str] = None
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def extract_filename(url: str, content_disposition: Optional[str] = None) -> str:
|
|
30
|
+
"""Deduces filename from Content-Disposition header or URL path."""
|
|
31
|
+
if content_disposition:
|
|
32
|
+
# Match filename="example.zip" or filename*=UTF-8''example.zip
|
|
33
|
+
match = re.search(r'filename\*?=(?:UTF-8\'\')?["\']?([^"\';]+)["\']?', content_disposition, re.IGNORECASE)
|
|
34
|
+
if match:
|
|
35
|
+
return os.path.basename(unquote(match.group(1).strip()))
|
|
36
|
+
|
|
37
|
+
parsed = urlparse(url)
|
|
38
|
+
path_name = os.path.basename(unquote(parsed.path))
|
|
39
|
+
if path_name and "." in path_name:
|
|
40
|
+
return path_name
|
|
41
|
+
|
|
42
|
+
return "downloaded_file.bin"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def probe_url(url: str, timeout: float = 10.0) -> ProbeResult:
|
|
46
|
+
"""Probes the remote server to determine file size, range support, and filename."""
|
|
47
|
+
http = urllib3.PoolManager()
|
|
48
|
+
headers = {
|
|
49
|
+
"User-Agent": "Plexo-CLI/0.1.0 (Bandwidth-Bonding-Engine)",
|
|
50
|
+
"Accept-Encoding": "identity",
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
try:
|
|
54
|
+
# First attempt with HEAD
|
|
55
|
+
resp = http.request("HEAD", url, headers=headers, timeout=timeout, redirect=True)
|
|
56
|
+
if resp.status >= 400:
|
|
57
|
+
# Fallback to GET with 1 byte range if HEAD is blocked
|
|
58
|
+
headers["Range"] = "bytes=0-0"
|
|
59
|
+
resp = http.request("GET", url, headers=headers, timeout=timeout, redirect=True)
|
|
60
|
+
except Exception:
|
|
61
|
+
# Fallback to GET with range
|
|
62
|
+
headers["Range"] = "bytes=0-0"
|
|
63
|
+
resp = http.request("GET", url, headers=headers, timeout=timeout, redirect=True)
|
|
64
|
+
|
|
65
|
+
final_url = url
|
|
66
|
+
if hasattr(resp, "url") and resp.url:
|
|
67
|
+
candidate_url = resp.url
|
|
68
|
+
if candidate_url.startswith("http://") or candidate_url.startswith("https://"):
|
|
69
|
+
final_url = candidate_url
|
|
70
|
+
headers_resp = resp.headers
|
|
71
|
+
|
|
72
|
+
# Check range support
|
|
73
|
+
accepts_ranges = False
|
|
74
|
+
if resp.status == 206:
|
|
75
|
+
accepts_ranges = True
|
|
76
|
+
elif headers_resp.get("Accept-Ranges", "").lower() == "bytes":
|
|
77
|
+
accepts_ranges = True
|
|
78
|
+
|
|
79
|
+
# Determine total size
|
|
80
|
+
total_bytes = 0
|
|
81
|
+
content_range = headers_resp.get("Content-Range")
|
|
82
|
+
if content_range:
|
|
83
|
+
# format: bytes 0-0/104857600
|
|
84
|
+
match = re.search(r"/(\d+)", content_range)
|
|
85
|
+
if match:
|
|
86
|
+
total_bytes = int(match.group(1))
|
|
87
|
+
accepts_ranges = True
|
|
88
|
+
|
|
89
|
+
if not total_bytes:
|
|
90
|
+
cl = headers_resp.get("Content-Length")
|
|
91
|
+
if cl and cl.isdigit():
|
|
92
|
+
total_bytes = int(cl)
|
|
93
|
+
|
|
94
|
+
cd = headers_resp.get("Content-Disposition")
|
|
95
|
+
filename = extract_filename(final_url, cd)
|
|
96
|
+
content_type = headers_resp.get("Content-Type", "application/octet-stream")
|
|
97
|
+
etag = headers_resp.get("ETag")
|
|
98
|
+
|
|
99
|
+
return ProbeResult(
|
|
100
|
+
url=final_url,
|
|
101
|
+
total_bytes=total_bytes,
|
|
102
|
+
accepts_ranges=accepts_ranges,
|
|
103
|
+
filename=filename,
|
|
104
|
+
content_type=content_type,
|
|
105
|
+
etag=etag,
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
class BoundRangeClient:
|
|
110
|
+
"""Performs HTTP GET range downloads strictly bound to a local adapter IP."""
|
|
111
|
+
|
|
112
|
+
def __init__(self, local_ip: str, timeout: float = 15.0):
|
|
113
|
+
self.local_ip = local_ip
|
|
114
|
+
self.timeout = timeout
|
|
115
|
+
# Bind urllib3 socket connections to (local_ip, 0)
|
|
116
|
+
self._pool = urllib3.PoolManager(
|
|
117
|
+
source_address=(local_ip, 0),
|
|
118
|
+
timeout=urllib3.Timeout(connect=timeout, read=timeout),
|
|
119
|
+
retries=urllib3.Retry(total=3, backoff_factor=0.5),
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
def fetch_range(
|
|
123
|
+
self,
|
|
124
|
+
url: str,
|
|
125
|
+
start_byte: int,
|
|
126
|
+
end_byte: int,
|
|
127
|
+
chunk_size: int = 64 * 1024, # 64 KB read buffer
|
|
128
|
+
on_bytes: Optional[Callable[[int], None]] = None,
|
|
129
|
+
) -> Generator[bytes, None, None]:
|
|
130
|
+
"""Fetches [start_byte, end_byte] inclusive through the bound adapter."""
|
|
131
|
+
headers = {
|
|
132
|
+
"User-Agent": "Plexo-CLI/0.1.0 (Bandwidth-Bonding-Engine)",
|
|
133
|
+
"Range": f"bytes={start_byte}-{end_byte}",
|
|
134
|
+
"Accept-Encoding": "identity",
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
resp = self._pool.request("GET", url, headers=headers, preload_content=False)
|
|
138
|
+
try:
|
|
139
|
+
if resp.status not in (200, 206):
|
|
140
|
+
raise IOError(f"HTTP Range request failed with status {resp.status}")
|
|
141
|
+
|
|
142
|
+
for data in resp.stream(chunk_size):
|
|
143
|
+
if data:
|
|
144
|
+
if on_bytes:
|
|
145
|
+
on_bytes(len(data))
|
|
146
|
+
yield data
|
|
147
|
+
finally:
|
|
148
|
+
resp.release_conn()
|