heathrow 0.2__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.
heathrow/bencode.py ADDED
@@ -0,0 +1,92 @@
1
+ Bencodable = dict | str | int | bytes | tuple
2
+ def bencode(data: Bencodable) -> bytes:
3
+ if isinstance(data, bytes):
4
+ return f"{len(data)}:".encode() + data
5
+ if isinstance(data, str):
6
+ return f"{len(data.encode())}:{data}".encode()
7
+ if isinstance(data, int):
8
+ return f"i{str(data)}e".encode()
9
+ if isinstance(data, tuple):
10
+ list_string = b"l"
11
+ for i in data:
12
+ list_string += bencode(i)
13
+ list_string += b"e"
14
+ return list_string
15
+ if isinstance(data, dict):
16
+ keys = sorted(data.keys())
17
+ dict_string = b"d"
18
+ for key in keys:
19
+ dict_string += bencode(key)
20
+ dict_string += bencode(data[key])
21
+ dict_string += b"e"
22
+ return dict_string
23
+
24
+ def _bdecode_string(raw: bytes) -> tuple[bytes, int]:
25
+ strlen = int(raw.split(b":", 1)[0])
26
+ length = strlen + 1 + raw.find(b":")
27
+ result = raw[raw.find(b":")+1:raw.find(b":")+1+strlen]
28
+ return result, length
29
+
30
+ def _bdecode_int(raw: bytes) -> tuple[int, int]:
31
+ if raw.startswith(b"i0") and not raw.startswith(b"i0e"):
32
+ raise ValueError("Cannot parse leading zero in integer")
33
+ if raw.startswith(b"i-0"):
34
+ raise ValueError("Cannot parse negative zero in integer")
35
+ result = int(raw[1:raw.find(b"e")])
36
+ return result, raw.find(b"e") + 1
37
+
38
+ def _bdecode_list(raw: bytes) -> tuple[tuple, int]:
39
+ result = []
40
+ wip_raw = raw[1:]
41
+ total_strlen = 1
42
+ while wip_raw[0:1] != b"e":
43
+ step, strlen = _bdecode(wip_raw)
44
+ wip_raw = wip_raw[strlen:]
45
+ result.append(step)
46
+ total_strlen += strlen
47
+ return tuple(result), total_strlen + 1
48
+
49
+ def _bdecode_dict(raw: bytes) -> tuple[dict, int]:
50
+ result = {}
51
+ wip_raw = raw[1:]
52
+ total_strlen = 1
53
+ keys_list = []
54
+ while wip_raw[0:1] != b"e":
55
+ key, strlen = _bdecode(wip_raw)
56
+ keys_list.append(key)
57
+ wip_raw = wip_raw[strlen:]
58
+ total_strlen += strlen
59
+ val, strlen = _bdecode(wip_raw)
60
+ wip_raw = wip_raw[strlen:]
61
+ total_strlen += strlen
62
+ result[key] = val
63
+ if keys_list != sorted(keys_list):
64
+ raise ValueError(f"Bencoded dictionary {raw[:total_strlen+1]} not sorted")
65
+ return result, total_strlen + 1
66
+
67
+ def _bdecode(raw: bytes) -> tuple[Bencodable, int]:
68
+ if raw[0] in b"1234567890":
69
+ return _bdecode_string(raw)
70
+ elif raw[0:1] == b"i":
71
+ return _bdecode_int(raw)
72
+ elif raw[0:1] == b"l":
73
+ return _bdecode_list(raw)
74
+ elif raw[0:1] == b"d":
75
+ return _bdecode_dict(raw)
76
+ else:
77
+ raise ValueError(f"Unknown header in byte string b\"{chr(raw[0])}\"")
78
+
79
+ def bdecode_start(raw: bytes) -> tuple[Bencodable, bytes]:
80
+ """Decode a bencoded bytestring, possibly with some data attached to the tail.
81
+
82
+ Return a tuple (decoded, tail).
83
+ """
84
+ res, length = _bdecode(raw)
85
+ return res, raw[length:]
86
+
87
+ def bdecode(raw: bytes) -> Bencodable:
88
+ """Decode a bencoded bytestring."""
89
+ res, length = _bdecode(raw)
90
+ if length != len(raw):
91
+ raise ValueError("Malformed bencoded data")
92
+ return res
heathrow/bitset.py ADDED
@@ -0,0 +1,94 @@
1
+
2
+ class Bitset:
3
+ """A bitset. Limited in length"""
4
+ def __init__(self, length: int):
5
+ self._bitset = 0
6
+ self._length = length
7
+
8
+ def __len__(self):
9
+ return self._length
10
+
11
+ def __setitem__(self, index: int, val: bool):
12
+ if index < 0:
13
+ index = self._length - index
14
+ if not 0 <= index < self._length:
15
+ raise IndexError
16
+ if val:
17
+ self._bitset |= (1 << (len(self) - 1 - index))
18
+ else:
19
+ self._bitset &= (1 << len(self)) - 1 - (1 << (len(self) - 1 - index))
20
+
21
+ def __getitem__(self, index: int | slice):
22
+ if isinstance(index, slice):
23
+ start = index.start if index.start is not None else 0
24
+ stop = index.stop if index.stop is not None else self._length
25
+ step = index.step if index.step is not None else 1
26
+
27
+ start = start if start >= 0 else self._length + start
28
+ stop = stop if stop >= 0 else self._length + stop
29
+
30
+ if start >= stop:
31
+ return Bitset(length=0)
32
+
33
+ # We don't need this
34
+ if step != 1:
35
+ raise IndexError("Cannot efficiently iterate with step not equal to 1")
36
+
37
+ result = Bitset(stop - start)
38
+ value = (self._bitset >> (self._length - stop)) & ((1 << (stop - start)) - 1)
39
+ result._bitset = value
40
+ return result
41
+
42
+ else:
43
+ if index < 0:
44
+ index = self._length - index
45
+ if not 0 <= index < self._length:
46
+ raise IndexError
47
+ return self._bitset & (1 << (len(self) - 1 - index)) > 0
48
+
49
+ def __invert__(self):
50
+ b = Bitset(self._length)
51
+ b._bitset = (1 << len(self)) - 1 - self._bitset
52
+ return b
53
+
54
+ def __and__(self, other):
55
+ if not isinstance(other, Bitset):
56
+ raise ValueError("Cannot operate on Bitset and non-Bitset")
57
+ if len(self) != len(other):
58
+ raise ValueError("Cannot operate on two Bitsets with different lengths")
59
+ b = Bitset(len(self))
60
+ b._bitset = self._bitset & other._bitset
61
+ return b
62
+
63
+ def __or__(self, other):
64
+ if not isinstance(other, Bitset):
65
+ raise ValueError("Cannot operate on Bitset and non-Bitset")
66
+ if len(self) != len(other):
67
+ raise ValueError("Cannot operate on two Bitsets with different lengths")
68
+ b = Bitset(len(self))
69
+ b._bitset = self._bitset | other._bitset
70
+ return b
71
+
72
+ def __hash__(self):
73
+ return hash(self._bitset, self._length)
74
+
75
+ def __bytes__(self):
76
+ padding = -self._length % 8
77
+ return (self._bitset << padding).to_bytes(length=(self._length + padding) // 8)
78
+
79
+ @property
80
+ def popcount(self):
81
+ return self._bitset.bit_count()
82
+
83
+ def to_indices(self):
84
+ return (i for i in range(len(self)) if self[i])
85
+
86
+ @classmethod
87
+ def from_bytes(cls, raw: bytes, length: int):
88
+ padding = -length % 8
89
+ b = cls(length)
90
+ b._bitset = int.from_bytes(raw) >> padding
91
+ return b
92
+
93
+ def __bool__(self):
94
+ return self._bitset > 0
heathrow/constants.py ADDED
@@ -0,0 +1 @@
1
+ PROTOCOL_NAME = "aquarius"
@@ -0,0 +1,8 @@
1
+ from .base import AlreadySubscribedError, Discovery
2
+ from .destination import DestinationDiscovery
3
+
4
+ __all__ = (
5
+ "Discovery",
6
+ "DestinationDiscovery",
7
+ "AlreadySubscribedError",
8
+ )
@@ -0,0 +1,42 @@
1
+ from abc import ABC, abstractmethod
2
+
3
+ from heathrow.economy import Economy
4
+
5
+
6
+ class AlreadySubscribedError(ValueError):
7
+ pass
8
+
9
+
10
+ class Discovery(ABC):
11
+ """Class for "Discovery" modules.
12
+
13
+ Discovery module is responsible for getting Reticulum links at which potential peers may reside.
14
+
15
+ It exposes two methods to subscribe and unsubscribe economies to individual fileset IDs,
16
+ as well as two methods to start and stop discovery.
17
+ """
18
+
19
+ @abstractmethod
20
+ def start(self):
21
+ """Start any process loop(s) needed for the Discovery to run.
22
+
23
+ Do not block indefinitely.
24
+ """
25
+
26
+ @abstractmethod
27
+ def stop(self):
28
+ """Stop the Discovery's process loop(s)."""
29
+
30
+ @abstractmethod
31
+ def subscribe(self, fileset_id: str, economy: "Economy"):
32
+ """Attach an Economy as a listener for new peers for fileset fileset_id.
33
+
34
+ Attaching multiple Economies to the same fileset_id should not be allowed. Raise an AlreadySubscribedError.
35
+ """
36
+
37
+ @abstractmethod
38
+ def unsubscribe(self, fileset_id: str):
39
+ """Detach the current Economy from fileset_id.
40
+
41
+ Detaching a non-existent Economy from a fileset_id should not be allowed. Raise a KeyError.
42
+ """
@@ -0,0 +1,118 @@
1
+ import logging
2
+ from dataclasses import dataclass
3
+ from functools import partial
4
+ from threading import Condition, Thread
5
+ from time import sleep
6
+
7
+ import RNS
8
+
9
+ from heathrow.discovery import AlreadySubscribedError, Discovery
10
+ from heathrow.economy import Economy
11
+ from heathrow.wire import Direction, ProtocolError, Wire, WireFactory
12
+
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+ PROTOCOL_NAME = "aquarius-protocol-v0" # From v0
17
+ ANNOUNCEMENT_INTERVAL_SECONDS = 15
18
+
19
+
20
+ class AnnounceHandler:
21
+ def __init__(self, fileset_id: str, discovery: "DestinationDiscovery"):
22
+ self.aspect_filter = f"{PROTOCOL_NAME}.{fileset_id}"
23
+ self.fileset_id = fileset_id
24
+ self.discovery = discovery
25
+
26
+ def received_announce(self, destination_hash, announced_identity, app_data):
27
+ if self.discovery.running:
28
+ logger.info(f"Received announce for fileset {self.fileset_id}.")
29
+ destination = RNS.Destination(announced_identity, RNS.Destination.OUT, RNS.Destination.SINGLE, PROTOCOL_NAME, self.fileset_id)
30
+ RNS.Link(destination, established_callback=partial(self.discovery._new_link, Direction.OUTGOING, self.fileset_id))
31
+
32
+
33
+ @dataclass
34
+ class Subscription:
35
+ economy: Economy
36
+ destination: RNS.Destination
37
+ announce_handler: AnnounceHandler
38
+
39
+
40
+ class DestinationDiscovery(Discovery):
41
+ """Destination-based discovery algorithm.
42
+
43
+ Announce a separate destination for every
44
+ fileset every ANNOUNCEMENT_INTERVAL_SECONDS.
45
+ Also listen to similar announcements for all destinations.
46
+ """
47
+
48
+ def __init__(self, identity: RNS.Identity, wirefactory: WireFactory):
49
+ self.identity = identity
50
+ self.subscriptions = {}
51
+ self._thread = None
52
+ self.running = False
53
+ self.wirefactory = wirefactory
54
+ self._early_stop_condition = Condition()
55
+
56
+ def _new_link_subthread(self, wire: Wire, fileset_id: str):
57
+ """Subthread for a new link. The library wants us to return fast, so we branch off."""
58
+ try:
59
+ fileset_id = wire.handshake()
60
+ except ProtocolError as exception:
61
+ logger.warning(f"Failed to establish a wire using {self.wirefactory} ({exception=})")
62
+ else:
63
+ self.subscriptions[fileset_id].economy.on_new_peer(wire)
64
+
65
+ def _new_link(self, direction: Direction, fileset_id: str, link: RNS.Link):
66
+ """Callback for a new link."""
67
+ if direction is Direction.OUTGOING:
68
+ wire = self.wirefactory.create_outgoing(link, self.identity, fileset_id)
69
+ else:
70
+ wire = self.wirefactory.create_incoming(link, self.identity, [fileset_id])
71
+ Thread(target=partial(self._new_link_subthread, wire, fileset_id)).start()
72
+
73
+ def subscribe(self, fileset_id: str, economy: Economy):
74
+ if fileset_id in self.subscriptions:
75
+ raise AlreadySubscribedError(f"Cannot subscribe to a second economy for fileset_id {fileset_id}")
76
+ self.subscriptions[fileset_id] = Subscription(
77
+ economy=economy,
78
+ destination=RNS.Destination(
79
+ self.identity,
80
+ RNS.Destination.IN,
81
+ RNS.Destination.SINGLE,
82
+ PROTOCOL_NAME,
83
+ fileset_id
84
+ ),
85
+ announce_handler=AnnounceHandler(fileset_id, self)
86
+ )
87
+ self.subscriptions[fileset_id].destination.set_link_established_callback(partial(self._new_link, Direction.INCOMING, fileset_id))
88
+ RNS.Transport.register_announce_handler(self.subscriptions[fileset_id].announce_handler)
89
+
90
+ def unsubscribe(self, fileset_id: str):
91
+ if fileset_id not in self.subscriptions:
92
+ raise KeyError(f"No economy to unsubscribe from fileset_id {fileset_id}")
93
+ logger.info(f"Unsubscribing from fileset {fileset_id}.")
94
+ RNS.Transport.deregister_announce_handler(self.subscriptions[fileset_id].announce_handler)
95
+ RNS.Transport.deregister_destination(self.subscriptions[fileset_id].destination)
96
+ del self.subscriptions[fileset_id]
97
+
98
+ def _run_announcements(self):
99
+ while self.running:
100
+ for fileset_id, subscription in self.subscriptions.items():
101
+ subscription.destination.announce()
102
+ logger.info(f"Announcing peer for fileset {fileset_id}.")
103
+ with self._early_stop_condition:
104
+ self._early_stop_condition.wait_for(lambda: not self.running, timeout=ANNOUNCEMENT_INTERVAL_SECONDS)
105
+
106
+
107
+ def start(self):
108
+ self.running = True
109
+ self._thread = Thread(target=self._run_announcements)
110
+ self._thread.start()
111
+
112
+ def stop(self):
113
+ self.running = False
114
+ with self._early_stop_condition:
115
+ self._early_stop_condition.notify()
116
+ if self._thread is not None:
117
+ self._thread.join()
118
+ self._thread = None
@@ -0,0 +1,9 @@
1
+ from .base import Driver
2
+ from .basic import BasicDriver
3
+ from .heathrow import HeathrowDriver
4
+
5
+ __all__ = (
6
+ "Driver",
7
+ "BasicDriver",
8
+ "HeathrowDriver",
9
+ )
@@ -0,0 +1,13 @@
1
+ from abc import ABC, abstractmethod
2
+
3
+ class Driver(ABC):
4
+ """Class for the driver modules.
5
+
6
+ Driver modules are responsible for putting the program modules together.
7
+
8
+ It can be a TUI, a graphical application, or a daemon for example.
9
+ """
10
+ @classmethod
11
+ @abstractmethod
12
+ def main(cls):
13
+ """Runs when the program starts."""
@@ -0,0 +1,39 @@
1
+ import logging
2
+ import os
3
+ import time
4
+ from pathlib import Path
5
+
6
+ import RNS
7
+
8
+ from heathrow.discovery import DestinationDiscovery
9
+ from heathrow.economy import ProtoEconomy, ReferenceEconomy
10
+ from heathrow.fileset import V1Fileset
11
+ from heathrow.wire import BufferedWireFactory, ReticulatedWireFactory, ReticulatedV2WireFactory
12
+
13
+ from .base import Driver
14
+
15
+
16
+ class BasicDriver(Driver):
17
+ @classmethod
18
+ def main(cls):
19
+ level = logging.DEBUG if os.environ.get("HEATHROW_DEBUG", "0") == "1" else logging.INFO
20
+ logging.basicConfig(level=level, format="%(asctime)s [%(name)s:%(levelname)s] %(message)s")
21
+ metadata_dir = Path(os.environ.get("HEATHROW_METADATA_DIR", "./metadata"))
22
+ download_dir = Path(os.environ.get("HEATHROW_DOWNLOAD_DIR", "./download"))
23
+ fileset_id = bytes.fromhex(os.environ.get("HEATHROW_FILESET_ID", ""))
24
+
25
+ reticulum = RNS.Reticulum()
26
+
27
+ discovery = DestinationDiscovery(RNS.Identity(), BufferedWireFactory)
28
+ # economy = ProtoEconomy(V1Fileset(fileset_id, metadata_dir=metadata_dir, download_dir=download_dir))
29
+ economy = ReferenceEconomy(V1Fileset(fileset_id, metadata_dir=metadata_dir, download_dir=download_dir))
30
+ economy.start()
31
+ discovery.subscribe("urn:btih:" + fileset_id.hex(), economy)
32
+ discovery.start()
33
+ try:
34
+ while True:
35
+ time.sleep(1)
36
+ except KeyboardInterrupt:
37
+ print("Shutting down...")
38
+ economy.stop()
39
+ discovery.stop()
@@ -0,0 +1,238 @@
1
+ import argparse
2
+ import curses
3
+ import logging
4
+ import os
5
+ import time
6
+ from collections import defaultdict
7
+ from dataclasses import dataclass
8
+ from importlib.metadata import version
9
+ from pathlib import Path
10
+ from threading import Condition, Thread
11
+
12
+ import RNS
13
+
14
+ import heathrow.discovery
15
+ import heathrow.wire
16
+ import heathrow.fileset
17
+ import heathrow.economy
18
+ from heathrow.bencode import bencode, bdecode
19
+
20
+ from ..base import Driver
21
+ from .constants import CONFIG_IDENTITY, CONFIG_METADATA, CONFIG_RESUME
22
+ from .util import string_to_fileset
23
+ from .windows import TUIWindow, SplashWindow
24
+
25
+
26
+ logger = logging.getLogger(__name__)
27
+
28
+
29
+ @dataclass
30
+ class HeathrowDriverConfig:
31
+ config_dir: Path
32
+ tui: bool
33
+ debug: bool
34
+ discovery: type[heathrow.discovery.Discovery]
35
+ wirefactory: heathrow.wire.WireFactory
36
+ fileset: type[heathrow.fileset.Fileset]
37
+ economy: type[heathrow.economy.Economy]
38
+ targets: list[str]
39
+
40
+ @classmethod
41
+ def parse_args(cls):
42
+ parser = argparse.ArgumentParser()
43
+ parser.add_argument("-c", "--config-directory", help="Heathrow config directory", type=Path, default=Path(os.path.expanduser("~/.config/heathrow")))
44
+ parser.add_argument("--tui", help="Show the TUI", action=argparse.BooleanOptionalAction, default=True)
45
+ parser.add_argument("--debug", help="Show extra debug information", action=argparse.BooleanOptionalAction, default=True)
46
+ parser.add_argument("--discovery", help="Discovery module to use", choices=[i.removesuffix("Discovery") for i in heathrow.discovery.__all__ if i.endswith("Discovery") and i != "Discovery"], default="Destination")
47
+ parser.add_argument("--wire", help="Wire module to use", choices=[i.removesuffix("WireFactory") for i in heathrow.wire.__all__ if i.endswith("WireFactory") and i != "WireFactory"], default="LTEPBuffered")
48
+ parser.add_argument("--fileset", help="Fileset module to use", choices=[i.removesuffix("Fileset") for i in heathrow.fileset.__all__ if i.endswith("Fileset") and i != "Fileset"], default="V1")
49
+ parser.add_argument("--economy", help="Economy module to use", choices=[i.removesuffix("Economy") for i in heathrow.economy.__all__ if i.endswith("Economy") and i != "Economy"], default="Reference")
50
+ parser.add_argument("targets", help="Start downloading these magnet link / file URIs", nargs="*")
51
+ args = parser.parse_args()
52
+ discovery = getattr(heathrow.discovery, args.discovery + "Discovery")
53
+ wirefactory = getattr(heathrow.wire, args.wirefactory + "WireFactory")
54
+ fileset = getattr(heathrow.fileset, args.fileset + "Fileset")
55
+ economy = getattr(heathrow.economy, args.economy + "Economy")
56
+ return cls(config_dir=args.config_directory, tui=args.tui, debug=args.debug, discovery=discovery, wirefactory=wirefactory, fileset=fileset, economy=economy, targets=args.targets)
57
+
58
+
59
+ TEXT_LOGO = """
60
+ ▄ ▄ ▄▄▄▄ ▄▄▄▄▄ ▄▄▄▄▄▄▄ ▄ ▄ ▄▄▄▄ ▄▄▄▄▄ ▄ ▄
61
+ ▄██▄▄█▀ ▄█▄▄ ▄██▄▄█▀ ▄█▀ ▄██▄▄█▀ ▄██▄ ▀ ▄█▀ ▄█▀ ▄█▀▄█ ▄█▀
62
+ █▀ █▀ ▄█▄▄ █▀ █▀ ▄█▀ █▀ █▀ █▀ █▀ ██▄▄█▀ ██▄██▄█▀
63
+ """.strip("\n")
64
+
65
+
66
+ MIN_COLS = 80
67
+ MIN_LINES = 24
68
+
69
+
70
+ class ScreenSizeErrorWindow(TUIWindow):
71
+ def draw(self):
72
+ self._window.clear()
73
+ cursor_y = (curses.LINES - len(TEXT_LOGO.split("\n"))) // 2 - 1
74
+ cursor_x = (curses.COLS - max(len(i) for i in TEXT_LOGO.split("\n"))) // 2
75
+ try:
76
+ for y, line in enumerate(TEXT_LOGO.split("\n")):
77
+ self._window.addstr(y, 0, line)
78
+ self._window.addstr(len(TEXT_LOGO.split("\n")), 0, f"Screen size {MIN_COLS}x{MIN_LINES} or more required for TUI mode.")
79
+ self._window.addstr(len(TEXT_LOGO.split("\n")) + 1, 0, f"(F10 to quit)")
80
+ except curses.error:
81
+ pass
82
+ self._window.refresh()
83
+
84
+ def handle_key(self, key: str | None):
85
+ return True
86
+
87
+
88
+ class HeathrowDriver(Driver):
89
+ @classmethod
90
+ def main(cls):
91
+ config = HeathrowDriverConfig.parse_args()
92
+ reticulum = RNS.Reticulum(loglevel=0)
93
+ self = cls(config)
94
+ self.run()
95
+
96
+ def create_resume_file(self, fileset: heathrow.fileset.Fileset):
97
+ resume_path = self.config.config_dir / CONFIG_RESUME / (fileset.fileset_id.replace(":", "_") + ".resume")
98
+ download_dir = fileset
99
+ if resume_path.exists():
100
+ pass # We have it already
101
+ else:
102
+ with open(resume_path, "wb") as f:
103
+ f.write(bencode({b"destination": str(fileset.download_directory.absolute()).encode()}))
104
+
105
+ def load_fileset_from_resume_file(self, resume_path: Path):
106
+ try:
107
+ with open(resume_path, "rb") as f:
108
+ resume = f.read()
109
+ parsed_resume = bdecode(resume)
110
+ download_dir = parsed_resume[b"destination"]
111
+ resume_name = resume_path.name.removesuffix(".resume")
112
+ metadata_path = self.config.config_dir / CONFIG_METADATA / (resume_name + ".torrent")
113
+ try:
114
+ fileset = self.config.fileset.from_file(metadata_path.read_bytes(), Path(download_dir.decode()), self.config.config_dir / CONFIG_METADATA)
115
+ except FileNotFoundError:
116
+ inferred_id = resume_name.replace("_", ":")
117
+ if not inferred_id.startswith("urn:"):
118
+ # TODO fileset V2
119
+ inferred_id = "urn:btih:" + inferred_id
120
+ fileset = self.config.fileset.from_id(inferred_id, Path(download_dir.decode()), self.config.config_dir / CONFIG_METADATA)
121
+ return fileset
122
+ except Exception as e:
123
+ raise ValueError(f"Could not load resume file at path {resume_path}") from e
124
+
125
+ def load_tracked_filesets(self):
126
+ filesets = []
127
+ for path in (self.config.config_dir / CONFIG_RESUME).iterdir():
128
+ if path.name.endswith(".resume"):
129
+ try:
130
+ filesets.append(self.load_fileset_from_resume_file(path))
131
+ except:
132
+ logger.warning(f"Could not resume download at {path}", exc_info=True)
133
+ for cliarg in self.config.targets:
134
+ fs = string_to_fileset(cliarg, self.config.fileset, Path(os.path.expanduser("~/Downloads")), self.config.config_dir / CONFIG_METADATA)
135
+ if fs is not None:
136
+ self._driver.create_resume_file(fs)
137
+ self._driver.add_fileset(fs)
138
+ return filesets
139
+
140
+ def add_fileset(self, fileset):
141
+ if fileset.fileset_id in self._tracked_filesets:
142
+ return
143
+ self._tracked_filesets[fileset.fileset_id] = fileset
144
+ self._economies[fileset.fileset_id] = self.config.economy(fileset)
145
+ self._discovery.subscribe(fileset.fileset_id, self._economies[fileset.fileset_id])
146
+ self._economies[fileset.fileset_id].start()
147
+
148
+ def init_filesets_and_economies(self):
149
+ """Separate from init function to call during e.g. the splash screen."""
150
+ self._tracked_filesets = {i.fileset_id: i for i in self.load_tracked_filesets()}
151
+ self._economies = {k: self.config.economy(v) for k, v in self._tracked_filesets.items()} # TODO add constructor to interface
152
+ for k, v in self._economies.items():
153
+ v.start()
154
+ self._discovery.subscribe(k, v)
155
+
156
+ def __init__(self, config):
157
+ self.config = config
158
+ (self.config.config_dir / CONFIG_METADATA).mkdir(parents=True, exist_ok=True)
159
+ (self.config.config_dir / CONFIG_RESUME).mkdir(parents=True, exist_ok=True)
160
+ logging.basicConfig(filename=self.config.config_dir / 'heathrow.log', level=logging.DEBUG, format="%(asctime)s [%(name)s:%(levelname)s] %(message)s")
161
+ if (identity := RNS.Identity.from_file(self.config.config_dir / CONFIG_IDENTITY)) is None:
162
+ identity = RNS.Identity()
163
+ identity.to_file(self.config.config_dir / CONFIG_IDENTITY)
164
+ (self.config.config_dir / CONFIG_IDENTITY).chmod(0o600)
165
+ self._identity = identity
166
+ self._discovery = self.config.discovery(self._identity, self.config.wirefactory) # TODO add constructor to interface
167
+ self._tracked_filesets_loaded = False
168
+ self._tracked_filesets = {}
169
+ self._economies = {}
170
+ self._download_speeds = defaultdict(float)
171
+ self._upload_speeds = defaultdict(float)
172
+ self._speedtest_stop = False
173
+ self._speedtest_thread = Thread(target=self._speedtest_loop)
174
+ self._early_stop_condition = Condition()
175
+ self._windows = []
176
+
177
+ def _speedtest_loop(self):
178
+ CURRENT_EXP = 0.3
179
+ last_downloads = defaultdict(int)
180
+ last_uploads = defaultdict(int)
181
+ while not self._speedtest_stop:
182
+ for economy in list(self._economies.keys()):
183
+ observed_down = self._economies[economy].status.downloaded_bytes - last_downloads[economy]
184
+ observed_up = self._economies[economy].status.uploaded_bytes - last_uploads[economy]
185
+ self._download_speeds[economy] = observed_down * CURRENT_EXP + self._download_speeds[economy] * (1 - CURRENT_EXP)
186
+ self._upload_speeds[economy] = observed_up * CURRENT_EXP + self._upload_speeds[economy] * (1 - CURRENT_EXP)
187
+ last_downloads[economy] = self._economies[economy].status.downloaded_bytes
188
+ last_uploads[economy] = self._economies[economy].status.uploaded_bytes
189
+ with self._early_stop_condition:
190
+ self._early_stop_condition.wait_for(lambda: self._speedtest_stop, timeout=1)
191
+
192
+ def run(self):
193
+ self._discovery.start()
194
+ if self.config.tui:
195
+ curses.wrapper(self.curses_main)
196
+ else:
197
+ self.init_filesets_and_economies()
198
+ self._driver._tracked_filesets_loaded = True
199
+ try:
200
+ while True:
201
+ time.sleep(1)
202
+ except KeyboardInterrupt:
203
+ pass
204
+ print(f"Thank you for using Heathrow {version('heathrow')}!")
205
+
206
+ def curses_main(self, stdscr):
207
+ stdscr.clear()
208
+ self._speedtest_thread.start()
209
+ self._windows.append(SplashWindow(stdscr, self))
210
+ size_error = ScreenSizeErrorWindow(stdscr, self)
211
+ curses.halfdelay(30)
212
+ curses.mousemask(curses.BUTTON1_CLICKED)
213
+ while True:
214
+ curses.update_lines_cols()
215
+ if self._windows:
216
+ if curses.COLS < MIN_COLS or curses.LINES < MIN_LINES:
217
+ size_error.draw()
218
+ else:
219
+ for window in self._windows:
220
+ window.draw()
221
+ try:
222
+ key = stdscr.getkey()
223
+ except curses.error:
224
+ key = None
225
+ active_window = len(self._windows) - 1
226
+ val = self._windows[active_window].handle_key(key)
227
+ if not val:
228
+ del self._windows[active_window]
229
+ else:
230
+ break
231
+
232
+ self._speedtest_stop = True
233
+ with self._early_stop_condition:
234
+ self._early_stop_condition.notify()
235
+ for economy in self._economies.values():
236
+ economy.stop()
237
+ self._discovery.stop()
238
+ self._speedtest_thread.join()
@@ -0,0 +1,5 @@
1
+ # Subpaths of the config directory
2
+
3
+ CONFIG_IDENTITY = "identity"
4
+ CONFIG_METADATA = "torrents"
5
+ CONFIG_RESUME = "resume"