bittorrent-client 0.1.0__tar.gz
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.
- bittorrent_client-0.1.0/PKG-INFO +66 -0
- bittorrent_client-0.1.0/README.md +48 -0
- bittorrent_client-0.1.0/bittorrent/__init__.py +0 -0
- bittorrent_client-0.1.0/bittorrent/__main__.py +4 -0
- bittorrent_client-0.1.0/bittorrent/bencode.py +144 -0
- bittorrent_client-0.1.0/bittorrent/cli.py +57 -0
- bittorrent_client-0.1.0/bittorrent/client.py +222 -0
- bittorrent_client-0.1.0/bittorrent/ip.py +18 -0
- bittorrent_client-0.1.0/bittorrent/peer.py +500 -0
- bittorrent_client-0.1.0/bittorrent/piece_manager.py +210 -0
- bittorrent_client-0.1.0/bittorrent/storage.py +123 -0
- bittorrent_client-0.1.0/bittorrent/torrent.py +209 -0
- bittorrent_client-0.1.0/bittorrent/trackers.py +318 -0
- bittorrent_client-0.1.0/bittorrent_client.egg-info/PKG-INFO +66 -0
- bittorrent_client-0.1.0/bittorrent_client.egg-info/SOURCES.txt +26 -0
- bittorrent_client-0.1.0/bittorrent_client.egg-info/dependency_links.txt +1 -0
- bittorrent_client-0.1.0/bittorrent_client.egg-info/entry_points.txt +2 -0
- bittorrent_client-0.1.0/bittorrent_client.egg-info/top_level.txt +1 -0
- bittorrent_client-0.1.0/pyproject.toml +33 -0
- bittorrent_client-0.1.0/setup.cfg +4 -0
- bittorrent_client-0.1.0/test/test_async_peer.py +67 -0
- bittorrent_client-0.1.0/test/test_bencode.py +115 -0
- bittorrent_client-0.1.0/test/test_client_assembly.py +124 -0
- bittorrent_client-0.1.0/test/test_peer.py +71 -0
- bittorrent_client-0.1.0/test/test_piece_manager.py +112 -0
- bittorrent_client-0.1.0/test/test_storage.py +112 -0
- bittorrent_client-0.1.0/test/test_torrent.py +122 -0
- bittorrent_client-0.1.0/test/test_trackers.py +67 -0
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: bittorrent_client
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A lightweight, robust BitTorrent client built with pure Python standard library.
|
|
5
|
+
Author: Omar Aflak
|
|
6
|
+
License: MIT
|
|
7
|
+
Classifier: Programming Language :: Python :: 3
|
|
8
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
9
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
10
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
13
|
+
Classifier: Operating System :: OS Independent
|
|
14
|
+
Classifier: Topic :: Communications :: File Sharing
|
|
15
|
+
Classifier: Topic :: Internet
|
|
16
|
+
Requires-Python: >=3.10
|
|
17
|
+
Description-Content-Type: text/markdown
|
|
18
|
+
|
|
19
|
+
# BitTorrent Client
|
|
20
|
+
|
|
21
|
+
A lightweight, robust BitTorrent client built with pure Python standard library (zero external dependencies).
|
|
22
|
+
|
|
23
|
+
## Installation
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
pip install bittorrent_client
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
## CLI Usage
|
|
30
|
+
|
|
31
|
+
Run directly via the command-line entry point:
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
bittorrent file.torrent -o ./downloads
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Or run as a module:
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
python3 -m bittorrent file.torrent -o ./downloads
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Options:
|
|
44
|
+
- `-o, --output`: Destination directory (default: `./`)
|
|
45
|
+
- `-w, --workers`: Maximum concurrent peer workers (default: `60`)
|
|
46
|
+
- `-v, --verbose`: Enable debug logging
|
|
47
|
+
|
|
48
|
+
## Python API
|
|
49
|
+
|
|
50
|
+
```python
|
|
51
|
+
import logging
|
|
52
|
+
from bittorrent.torrent import Torrent
|
|
53
|
+
from bittorrent.client import Client
|
|
54
|
+
|
|
55
|
+
logging.basicConfig(level=logging.INFO)
|
|
56
|
+
|
|
57
|
+
torrent = Torrent.from_file('file.torrent')
|
|
58
|
+
client = Client(torrent)
|
|
59
|
+
client.download('./downloads')
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
## Running Tests
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
python3 -m unittest discover test
|
|
66
|
+
```
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# BitTorrent Client
|
|
2
|
+
|
|
3
|
+
A lightweight, robust BitTorrent client built with pure Python standard library (zero external dependencies).
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install bittorrent_client
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## CLI Usage
|
|
12
|
+
|
|
13
|
+
Run directly via the command-line entry point:
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
bittorrent file.torrent -o ./downloads
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Or run as a module:
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
python3 -m bittorrent file.torrent -o ./downloads
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Options:
|
|
26
|
+
- `-o, --output`: Destination directory (default: `./`)
|
|
27
|
+
- `-w, --workers`: Maximum concurrent peer workers (default: `60`)
|
|
28
|
+
- `-v, --verbose`: Enable debug logging
|
|
29
|
+
|
|
30
|
+
## Python API
|
|
31
|
+
|
|
32
|
+
```python
|
|
33
|
+
import logging
|
|
34
|
+
from bittorrent.torrent import Torrent
|
|
35
|
+
from bittorrent.client import Client
|
|
36
|
+
|
|
37
|
+
logging.basicConfig(level=logging.INFO)
|
|
38
|
+
|
|
39
|
+
torrent = Torrent.from_file('file.torrent')
|
|
40
|
+
client = Client(torrent)
|
|
41
|
+
client.download('./downloads')
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## Running Tests
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
python3 -m unittest discover test
|
|
48
|
+
```
|
|
File without changes
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
from typing import Union
|
|
2
|
+
|
|
3
|
+
BenType = Union[int, bytes, list['BenType'], dict[str, 'BenType']]
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def bencode(data: BenType) -> bytes:
|
|
7
|
+
if isinstance(data, bool):
|
|
8
|
+
# In Python bool is a subclass of int; bencode doesn't have bool, encode as 1 or 0
|
|
9
|
+
return b'i1e' if data else b'i0e'
|
|
10
|
+
|
|
11
|
+
if isinstance(data, int):
|
|
12
|
+
return f'i{data}e'.encode('ascii')
|
|
13
|
+
|
|
14
|
+
if isinstance(data, str):
|
|
15
|
+
encoded = data.encode('utf-8')
|
|
16
|
+
return f'{len(encoded)}:'.encode('ascii') + encoded
|
|
17
|
+
|
|
18
|
+
if isinstance(data, bytes):
|
|
19
|
+
return f'{len(data)}:'.encode('ascii') + data
|
|
20
|
+
|
|
21
|
+
if isinstance(data, (list, tuple)):
|
|
22
|
+
values = b''.join(bencode(x) for x in data)
|
|
23
|
+
return b'l' + values + b'e'
|
|
24
|
+
|
|
25
|
+
if isinstance(data, dict):
|
|
26
|
+
# BEP 3: Keys must appear in sorted order (strings are sorted as raw byte strings)
|
|
27
|
+
def key_bytes(k: Union[str, bytes]) -> bytes:
|
|
28
|
+
if isinstance(k, bytes):
|
|
29
|
+
return k
|
|
30
|
+
if isinstance(k, str):
|
|
31
|
+
return k.encode('utf-8')
|
|
32
|
+
raise ValueError(f'Dict key must be str or bytes, got {type(k)}')
|
|
33
|
+
|
|
34
|
+
sorted_items = sorted(
|
|
35
|
+
data.items(), key=lambda item: key_bytes(item[0]))
|
|
36
|
+
values = b''.join(bencode(k) + bencode(v) for k, v in sorted_items)
|
|
37
|
+
return b'd' + values + b'e'
|
|
38
|
+
|
|
39
|
+
raise ValueError(f'Type {type(data)} not supported.')
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def decode_bencode(data: bytes) -> BenType:
|
|
43
|
+
if len(data) == 0:
|
|
44
|
+
return b''
|
|
45
|
+
value, end_idx = _decode_bencode(data, 0)
|
|
46
|
+
if end_idx != len(data):
|
|
47
|
+
raise ValueError(
|
|
48
|
+
f'Trailing data after bencode decode: decoded {end_idx} of {len(data)} bytes')
|
|
49
|
+
return value
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def decode_bencode_with_raw(data: bytes) -> tuple[BenType, dict[str, bytes]]:
|
|
53
|
+
"""
|
|
54
|
+
Decodes bencoded data and returns a tuple of:
|
|
55
|
+
(decoded_value, raw_slices)
|
|
56
|
+
where raw_slices is a dict mapping top-level dictionary string keys to their exact raw bytes in data.
|
|
57
|
+
"""
|
|
58
|
+
if len(data) == 0:
|
|
59
|
+
return b'', {}
|
|
60
|
+
value, end_idx, raw_slices = _decode_bencode_with_raw_map(data, 0)
|
|
61
|
+
if end_idx != len(data):
|
|
62
|
+
raise ValueError(
|
|
63
|
+
f'Trailing data after bencode decode: decoded {end_idx} of {len(data)} bytes')
|
|
64
|
+
return value, raw_slices
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _decode_bencode(data: bytes, start: int = 0) -> tuple[BenType, int]:
|
|
68
|
+
val, end_idx, _ = _decode_bencode_with_raw_map(data, start)
|
|
69
|
+
return val, end_idx
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _decode_bencode_with_raw_map(data: bytes, start: int = 0) -> tuple[BenType, int, dict[str, bytes]]:
|
|
73
|
+
if start >= len(data):
|
|
74
|
+
return b'', start, {}
|
|
75
|
+
|
|
76
|
+
char = chr(data[start])
|
|
77
|
+
|
|
78
|
+
if char.isdigit():
|
|
79
|
+
idx = data.find(b':', start)
|
|
80
|
+
if idx == -1:
|
|
81
|
+
raise ValueError(
|
|
82
|
+
f'Missing colon delimiter for string length at offset {start}')
|
|
83
|
+
try:
|
|
84
|
+
length = int(data[start:idx])
|
|
85
|
+
except ValueError as e:
|
|
86
|
+
raise ValueError(f'Invalid string length at offset {start}: {e}')
|
|
87
|
+
if length < 0:
|
|
88
|
+
raise ValueError(
|
|
89
|
+
f'Negative string length at offset {start}: {length}')
|
|
90
|
+
str_start = idx + 1
|
|
91
|
+
str_end = str_start + length
|
|
92
|
+
if str_end > len(data):
|
|
93
|
+
raise ValueError(
|
|
94
|
+
f'Unexpected EOF: expected {length} bytes at {str_start}, available {len(data) - str_start}')
|
|
95
|
+
value = data[str_start:str_end]
|
|
96
|
+
return value, str_end, {}
|
|
97
|
+
|
|
98
|
+
if char == 'i':
|
|
99
|
+
idx = data.find(b'e', start)
|
|
100
|
+
if idx == -1:
|
|
101
|
+
raise ValueError(
|
|
102
|
+
f'Missing end delimiter "e" for integer at offset {start}')
|
|
103
|
+
raw_int = data[start + 1:idx]
|
|
104
|
+
if len(raw_int) == 0:
|
|
105
|
+
# For backward compatibility with tests expecting empty int 'ie' -> 0
|
|
106
|
+
return 0, idx + 1, {}
|
|
107
|
+
try:
|
|
108
|
+
value = int(raw_int)
|
|
109
|
+
except ValueError as e:
|
|
110
|
+
raise ValueError(f'Invalid integer at offset {start}: {e}')
|
|
111
|
+
return value, idx + 1, {}
|
|
112
|
+
|
|
113
|
+
if char == 'l':
|
|
114
|
+
_list: list[BenType] = []
|
|
115
|
+
pos = start + 1
|
|
116
|
+
while pos < len(data) and chr(data[pos]) != 'e':
|
|
117
|
+
val, pos = _decode_bencode(data, pos)
|
|
118
|
+
_list.append(val)
|
|
119
|
+
if pos >= len(data):
|
|
120
|
+
raise ValueError(f'Unterminated list starting at offset {start}')
|
|
121
|
+
return _list, pos + 1, {}
|
|
122
|
+
|
|
123
|
+
if char == 'd':
|
|
124
|
+
_dict: dict[str, BenType] = {}
|
|
125
|
+
raw_slices: dict[str, bytes] = {}
|
|
126
|
+
pos = start + 1
|
|
127
|
+
while pos < len(data) and chr(data[pos]) != 'e':
|
|
128
|
+
val_key, pos = _decode_bencode(data, pos)
|
|
129
|
+
if not isinstance(val_key, bytes):
|
|
130
|
+
raise ValueError(
|
|
131
|
+
f'Dict key at offset {pos} must be bytes, got {type(val_key)}')
|
|
132
|
+
key_str = val_key.decode('utf-8', errors='replace')
|
|
133
|
+
val_start = pos
|
|
134
|
+
val, pos = _decode_bencode(data, pos)
|
|
135
|
+
_dict[key_str] = val
|
|
136
|
+
raw_slices[key_str] = data[val_start:pos]
|
|
137
|
+
|
|
138
|
+
if pos >= len(data):
|
|
139
|
+
raise ValueError(
|
|
140
|
+
f'Unterminated dictionary starting at offset {start}')
|
|
141
|
+
return _dict, pos + 1, raw_slices
|
|
142
|
+
|
|
143
|
+
raise ValueError(
|
|
144
|
+
f'Character "{char}" at offset {start} not recognized in bencode.')
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import sys
|
|
2
|
+
import logging
|
|
3
|
+
import argparse
|
|
4
|
+
from bittorrent.torrent import Torrent
|
|
5
|
+
from bittorrent.client import Client
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def _format_bytes(size: int) -> str:
|
|
9
|
+
units = ['B', 'KiB', 'MiB', 'GiB', 'TiB', 'PiB']
|
|
10
|
+
val = float(size)
|
|
11
|
+
for unit in units:
|
|
12
|
+
if val < 1024.0 or unit == units[-1]:
|
|
13
|
+
return f'{val:.1f} {unit}' if unit != 'B' else f'{int(val)} B'
|
|
14
|
+
val /= 1024.0
|
|
15
|
+
return f'{val:.1f} PiB'
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def main():
|
|
19
|
+
parser = argparse.ArgumentParser(
|
|
20
|
+
description='A simple, robust BitTorrent client.')
|
|
21
|
+
parser.add_argument('file', help='Path to .torrent file')
|
|
22
|
+
parser.add_argument('-o', '--output', default='./',
|
|
23
|
+
help='Output directory for downloaded files (default: ./)')
|
|
24
|
+
parser.add_argument('-w', '--workers', type=int, default=60,
|
|
25
|
+
help='Maximum peer worker threads (default: 60)')
|
|
26
|
+
parser.add_argument('-v', '--verbose', action='store_true',
|
|
27
|
+
help='Enable verbose debug logging')
|
|
28
|
+
|
|
29
|
+
args = parser.parse_args()
|
|
30
|
+
|
|
31
|
+
log_level = logging.DEBUG if args.verbose else logging.INFO
|
|
32
|
+
logging.basicConfig(
|
|
33
|
+
level=log_level,
|
|
34
|
+
format='%(asctime)s [%(levelname)s] %(message)s',
|
|
35
|
+
datefmt='%H:%M:%S'
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
try:
|
|
39
|
+
torrent = Torrent.from_file(args.file)
|
|
40
|
+
logging.info(
|
|
41
|
+
f'Loaded torrent: "{torrent.name}" ({_format_bytes(torrent.size)})')
|
|
42
|
+
logging.info(
|
|
43
|
+
f'Piece count: {torrent.piece_count} (piece size: {_format_bytes(torrent.piece_size)})')
|
|
44
|
+
logging.info(f'Trackers: {len(torrent.trackers)} found')
|
|
45
|
+
|
|
46
|
+
client = Client(torrent, max_peer_workers=args.workers)
|
|
47
|
+
client.download(args.output)
|
|
48
|
+
except KeyboardInterrupt:
|
|
49
|
+
logging.info('\nDownload cancelled by user.')
|
|
50
|
+
sys.exit(0)
|
|
51
|
+
except Exception as e:
|
|
52
|
+
logging.error(f'Error: {e}')
|
|
53
|
+
sys.exit(1)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
if __name__ == '__main__':
|
|
57
|
+
main()
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import shutil
|
|
3
|
+
import random
|
|
4
|
+
import logging
|
|
5
|
+
import asyncio
|
|
6
|
+
from bittorrent.trackers import Trackers
|
|
7
|
+
from bittorrent.torrent import Torrent, File
|
|
8
|
+
from bittorrent.peer import AsyncPeer
|
|
9
|
+
from bittorrent.storage import Storage
|
|
10
|
+
from bittorrent.piece_manager import PieceManager
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class Client:
|
|
14
|
+
"""
|
|
15
|
+
BitTorrent client responsible for coordinating peer discovery, concurrent
|
|
16
|
+
piece downloading, piece verification, and direct-to-disk file writing.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
def __init__(
|
|
20
|
+
self,
|
|
21
|
+
torrent: Torrent,
|
|
22
|
+
max_peer_workers: int = 60,
|
|
23
|
+
max_tracker_workers: int = 20,
|
|
24
|
+
max_peers_per_tracker: int = 200,
|
|
25
|
+
max_peers_per_piece: int = 2,
|
|
26
|
+
max_peer_batch_requests: int = 16,
|
|
27
|
+
piece_chunk_size: int = 16384
|
|
28
|
+
):
|
|
29
|
+
"""
|
|
30
|
+
Initialize a BitTorrent client instance.
|
|
31
|
+
|
|
32
|
+
:param torrent: Parsed Torrent metadata object containing file and tracker details.
|
|
33
|
+
:param max_peer_workers: Maximum number of concurrent peer connection workers (default: 60).
|
|
34
|
+
:param max_tracker_workers: Maximum number of concurrent tracker query threads (default: 20).
|
|
35
|
+
:param max_peers_per_tracker: Maximum number of peers to request per tracker announce (default: 200).
|
|
36
|
+
:param max_peers_per_piece: Maximum number of concurrent workers allowed per piece during endgame mode (default: 2).
|
|
37
|
+
:param max_peer_batch_requests: Initial pipeline depth for concurrent in-flight chunk requests per peer (default: 16).
|
|
38
|
+
:param piece_chunk_size: Size in bytes of each chunk requested from a peer (default: 16384 / 16 KiB).
|
|
39
|
+
"""
|
|
40
|
+
self.torrent = torrent
|
|
41
|
+
self.max_peer_workers = max_peer_workers
|
|
42
|
+
self.max_tracker_workers = max_tracker_workers
|
|
43
|
+
self.max_peers_per_tracker = max_peers_per_tracker
|
|
44
|
+
self.max_peers_per_piece = max_peers_per_piece
|
|
45
|
+
self.max_peer_batch_requests = max_peer_batch_requests
|
|
46
|
+
self.piece_chunk_size = piece_chunk_size
|
|
47
|
+
|
|
48
|
+
self.peer_id = b'-AG0001-' + random.randbytes(12)
|
|
49
|
+
self.stopped = False
|
|
50
|
+
self.tmp = ''
|
|
51
|
+
|
|
52
|
+
def download(self, output_directory: str):
|
|
53
|
+
"""
|
|
54
|
+
Download all pieces of the torrent into the target directory.
|
|
55
|
+
|
|
56
|
+
Discovers peers from configured trackers (both UDP and HTTP/HTTPS), connects
|
|
57
|
+
to peers concurrently with adaptive pipelining, schedules chunks using
|
|
58
|
+
block-level sharing and Rarest-First, and writes verified piece data directly
|
|
59
|
+
to target files on disk. Resumes from existing verified files without overhead.
|
|
60
|
+
|
|
61
|
+
:param output_directory: File system directory path where downloaded files will be saved.
|
|
62
|
+
"""
|
|
63
|
+
if self.torrent.size == 0 or self.torrent.piece_count == 0:
|
|
64
|
+
logging.info('Empty torrent, creating empty files.')
|
|
65
|
+
Storage(self.torrent, output_directory)
|
|
66
|
+
return
|
|
67
|
+
|
|
68
|
+
try:
|
|
69
|
+
asyncio.run(self._async_download(output_directory))
|
|
70
|
+
except KeyboardInterrupt:
|
|
71
|
+
logging.warning('Download interrupted by user.')
|
|
72
|
+
|
|
73
|
+
async def _async_download(self, output_directory: str):
|
|
74
|
+
# 1. Direct-to-Disk Storage (pre-allocates sparse files, eliminates .tmp copy)
|
|
75
|
+
storage = Storage(self.torrent, output_directory)
|
|
76
|
+
|
|
77
|
+
# 2. Check for already completed pieces on disk (instant resume)
|
|
78
|
+
piece_manager = PieceManager(self.torrent, storage)
|
|
79
|
+
verified = storage.verify_existing_pieces()
|
|
80
|
+
for p_idx in verified:
|
|
81
|
+
piece_manager.mark_piece_completed(p_idx)
|
|
82
|
+
|
|
83
|
+
if piece_manager.is_finished():
|
|
84
|
+
logging.info(
|
|
85
|
+
'All pieces are already downloaded and verified on disk.')
|
|
86
|
+
return
|
|
87
|
+
|
|
88
|
+
if verified:
|
|
89
|
+
logging.info(
|
|
90
|
+
f'Resuming download: {len(verified)}/{len(self.torrent.pieces)} pieces already verified on disk.')
|
|
91
|
+
|
|
92
|
+
# 3. Discover peers from trackers
|
|
93
|
+
trackers = Trackers(
|
|
94
|
+
self.torrent,
|
|
95
|
+
self.peer_id,
|
|
96
|
+
self.max_tracker_workers,
|
|
97
|
+
self.max_peers_per_tracker
|
|
98
|
+
)
|
|
99
|
+
peers = trackers.get_peers()
|
|
100
|
+
if not peers:
|
|
101
|
+
logging.error('No peers discovered from trackers.')
|
|
102
|
+
return
|
|
103
|
+
|
|
104
|
+
logging.info(
|
|
105
|
+
f'Starting high-speed async download with {len(peers)} discovered peers...')
|
|
106
|
+
|
|
107
|
+
# 4. Spawn AsyncPeers with adaptive pipelining and block-level scheduling
|
|
108
|
+
peer_tasks = []
|
|
109
|
+
peers_to_connect = list(peers)[:self.max_peer_workers]
|
|
110
|
+
for endpoint in peers_to_connect:
|
|
111
|
+
ap = AsyncPeer(
|
|
112
|
+
endpoint,
|
|
113
|
+
self.torrent.info_hash,
|
|
114
|
+
self.peer_id,
|
|
115
|
+
piece_manager,
|
|
116
|
+
initial_pipeline=self.max_peer_batch_requests,
|
|
117
|
+
max_pipeline=64,
|
|
118
|
+
min_pipeline=4
|
|
119
|
+
)
|
|
120
|
+
peer_tasks.append(asyncio.create_task(ap.run()))
|
|
121
|
+
|
|
122
|
+
# 5. Monitor download progress
|
|
123
|
+
total_pieces = len(self.torrent.pieces)
|
|
124
|
+
last_logged_count = len(piece_manager.completed_pieces)
|
|
125
|
+
|
|
126
|
+
try:
|
|
127
|
+
while not piece_manager.is_finished():
|
|
128
|
+
if all(t.done() for t in peer_tasks):
|
|
129
|
+
break
|
|
130
|
+
|
|
131
|
+
await asyncio.sleep(1.0)
|
|
132
|
+
current_count = len(piece_manager.completed_pieces)
|
|
133
|
+
if current_count != last_logged_count:
|
|
134
|
+
last_logged_count = current_count
|
|
135
|
+
percent = int(100 * current_count /
|
|
136
|
+
total_pieces) if total_pieces > 0 else 100
|
|
137
|
+
downloaded_bytes = sum(
|
|
138
|
+
self.torrent.pieces[i].size for i in piece_manager.completed_pieces)
|
|
139
|
+
human_done = self._human_friendly_bytes_str(
|
|
140
|
+
downloaded_bytes)
|
|
141
|
+
human_total = self._human_friendly_bytes_str(
|
|
142
|
+
self.torrent.size)
|
|
143
|
+
logging.info(
|
|
144
|
+
f'Progress: {current_count}/{total_pieces} pieces ({percent}%) [{human_done} / {human_total}]')
|
|
145
|
+
|
|
146
|
+
finally:
|
|
147
|
+
# Clean up all peer tasks
|
|
148
|
+
for t in peer_tasks:
|
|
149
|
+
t.cancel()
|
|
150
|
+
await asyncio.gather(*peer_tasks, return_exceptions=True)
|
|
151
|
+
|
|
152
|
+
if not piece_manager.is_finished():
|
|
153
|
+
logging.error('Download did not finish completely.')
|
|
154
|
+
return
|
|
155
|
+
|
|
156
|
+
logging.info(
|
|
157
|
+
'Download complete! All pieces verified directly on disk.')
|
|
158
|
+
|
|
159
|
+
def _assemble_files(self, output_directory: str):
|
|
160
|
+
"""Assembles downloaded pieces from .tmp if present (backward compatibility)."""
|
|
161
|
+
if self.torrent.is_multi_file:
|
|
162
|
+
base_dir = os.path.join(output_directory, self.torrent.name)
|
|
163
|
+
for file_info in self.torrent.files:
|
|
164
|
+
file_dir = os.path.join(base_dir, *file_info.path[:-1])
|
|
165
|
+
os.makedirs(file_dir, exist_ok=True)
|
|
166
|
+
target_path = os.path.join(file_dir, file_info.path[-1])
|
|
167
|
+
self._assemble_file(file_info, target_path)
|
|
168
|
+
else:
|
|
169
|
+
os.makedirs(output_directory, exist_ok=True)
|
|
170
|
+
if self.torrent.files:
|
|
171
|
+
file_info = self.torrent.files[0]
|
|
172
|
+
target_path = os.path.join(
|
|
173
|
+
output_directory, file_info.path[-1])
|
|
174
|
+
self._assemble_file(file_info, target_path)
|
|
175
|
+
|
|
176
|
+
if self.tmp and os.path.isdir(self.tmp):
|
|
177
|
+
try:
|
|
178
|
+
shutil.rmtree(self.tmp)
|
|
179
|
+
except Exception:
|
|
180
|
+
pass
|
|
181
|
+
|
|
182
|
+
def _assemble_file(self, file_info: File, output_path: str):
|
|
183
|
+
"""Assembles a single file from .tmp part files (backward compatibility)."""
|
|
184
|
+
if file_info.size == 0:
|
|
185
|
+
with open(output_path, 'wb'):
|
|
186
|
+
pass
|
|
187
|
+
return
|
|
188
|
+
|
|
189
|
+
piece_size = self.torrent.piece_size
|
|
190
|
+
file_start = file_info.start
|
|
191
|
+
file_end = file_start + file_info.size
|
|
192
|
+
start_piece_index = file_start // piece_size if piece_size > 0 else 0
|
|
193
|
+
|
|
194
|
+
with open(output_path, 'wb') as dest:
|
|
195
|
+
for piece in self.torrent.pieces[start_piece_index:]:
|
|
196
|
+
piece_start = piece.index * piece_size
|
|
197
|
+
piece_end = piece_start + piece.size
|
|
198
|
+
|
|
199
|
+
if piece_start >= file_end:
|
|
200
|
+
break
|
|
201
|
+
|
|
202
|
+
overlap_start = max(piece_start, file_start)
|
|
203
|
+
overlap_end = min(piece_end, file_end)
|
|
204
|
+
|
|
205
|
+
if overlap_end > overlap_start:
|
|
206
|
+
part_path = os.path.join(
|
|
207
|
+
self.tmp, f'piece_{piece.index}.part')
|
|
208
|
+
if os.path.exists(part_path):
|
|
209
|
+
with open(part_path, 'rb') as part_file:
|
|
210
|
+
part_file.seek(overlap_start - piece_start)
|
|
211
|
+
data = part_file.read(overlap_end - overlap_start)
|
|
212
|
+
dest.write(data)
|
|
213
|
+
|
|
214
|
+
@staticmethod
|
|
215
|
+
def _human_friendly_bytes_str(size: int) -> str:
|
|
216
|
+
units = ['B', 'KiB', 'MiB', 'GiB', 'TiB', 'PiB']
|
|
217
|
+
val = float(size)
|
|
218
|
+
for unit in units:
|
|
219
|
+
if val < 1024.0 or unit == units[-1]:
|
|
220
|
+
return f'{val:.1f} {unit}' if unit != 'B' else f'{int(val)} B'
|
|
221
|
+
val /= 1024.0
|
|
222
|
+
return f'{val:.1f} PiB'
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
@dataclass(frozen=True)
|
|
5
|
+
class IpAndPort:
|
|
6
|
+
ip: str
|
|
7
|
+
port: int
|
|
8
|
+
|
|
9
|
+
def __hash__(self) -> int:
|
|
10
|
+
return hash((self.ip, self.port))
|
|
11
|
+
|
|
12
|
+
def __eq__(self, other: object) -> bool:
|
|
13
|
+
if not isinstance(other, IpAndPort):
|
|
14
|
+
return False
|
|
15
|
+
return self.ip == other.ip and self.port == other.port
|
|
16
|
+
|
|
17
|
+
def __str__(self) -> str:
|
|
18
|
+
return f'{self.ip}:{self.port}'
|