torrdown 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.
- torrdown/__init__.py +3 -0
- torrdown/__main__.py +5 -0
- torrdown/cli.py +330 -0
- torrdown/config.py +149 -0
- torrdown/engine.py +334 -0
- torrdown/magnets.py +46 -0
- torrdown/sources.py +80 -0
- torrdown/state.py +90 -0
- torrdown-0.1.0.dist-info/METADATA +194 -0
- torrdown-0.1.0.dist-info/RECORD +13 -0
- torrdown-0.1.0.dist-info/WHEEL +4 -0
- torrdown-0.1.0.dist-info/entry_points.txt +2 -0
- torrdown-0.1.0.dist-info/licenses/LICENSE +21 -0
torrdown/__init__.py
ADDED
torrdown/__main__.py
ADDED
torrdown/cli.py
ADDED
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Command-line interface for TorrDown.
|
|
3
|
+
|
|
4
|
+
Subcommands: add / batch / resume / status / list / config.
|
|
5
|
+
Run bare (`torrdown` with no subcommand) for the interactive menu."""
|
|
6
|
+
import argparse
|
|
7
|
+
import signal
|
|
8
|
+
import sys
|
|
9
|
+
|
|
10
|
+
from . import __version__
|
|
11
|
+
from .config import Config
|
|
12
|
+
from .state import StateStore
|
|
13
|
+
from .engine import TorrentEngine
|
|
14
|
+
from .magnets import read_magnets_file, archive_results
|
|
15
|
+
from .sources import resolve_token, resolve_many, list_torrent_files, SourceError
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
# ---------------------------------------------------------------- engine glue
|
|
19
|
+
|
|
20
|
+
def _make_engine(cfg):
|
|
21
|
+
state = StateStore(str(cfg.state_file))
|
|
22
|
+
state.load()
|
|
23
|
+
|
|
24
|
+
def archive_one(source, status, reason):
|
|
25
|
+
# Only rewrites lines that actually appear in the magnets file, so
|
|
26
|
+
# this is a no-op for add/interactive sources that aren't in it.
|
|
27
|
+
archive_results(str(cfg.magnets_file), {source: (status, reason)})
|
|
28
|
+
|
|
29
|
+
engine = TorrentEngine(
|
|
30
|
+
cfg.download_dir, state,
|
|
31
|
+
port=cfg.port, max_concurrent=cfg.max_concurrent,
|
|
32
|
+
metadata_timeout=cfg.metadata_timeout,
|
|
33
|
+
checkpoint_interval=cfg.checkpoint_interval,
|
|
34
|
+
heartbeat_delay=cfg.heartbeat_delay,
|
|
35
|
+
on_result=archive_one,
|
|
36
|
+
)
|
|
37
|
+
signal.signal(signal.SIGINT, lambda *_: engine.request_shutdown())
|
|
38
|
+
signal.signal(signal.SIGTERM, lambda *_: engine.request_shutdown())
|
|
39
|
+
return engine
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _download(cfg, sources, resume=True):
|
|
43
|
+
"""Shared path for every downloading command: resume interrupted work,
|
|
44
|
+
add the requested sources, run until done. `sources` is a list of
|
|
45
|
+
(source, label)."""
|
|
46
|
+
cfg.ensure_dirs()
|
|
47
|
+
engine = _make_engine(cfg)
|
|
48
|
+
try:
|
|
49
|
+
if resume:
|
|
50
|
+
engine.resume_from_state()
|
|
51
|
+
for source, label in sources:
|
|
52
|
+
engine.add_source(source, label)
|
|
53
|
+
if not engine.has_work():
|
|
54
|
+
print("Nothing to download.")
|
|
55
|
+
return
|
|
56
|
+
engine.run()
|
|
57
|
+
finally:
|
|
58
|
+
engine.close()
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
# ------------------------------------------------------------------- commands
|
|
62
|
+
|
|
63
|
+
def cmd_add(cfg, args):
|
|
64
|
+
sources, errors = resolve_many(args.sources, cfg.torrents_dir)
|
|
65
|
+
for err in errors:
|
|
66
|
+
print(f"Skipping: {err}")
|
|
67
|
+
if args.name and len(sources) == 1:
|
|
68
|
+
sources = [(sources[0][0], args.name)]
|
|
69
|
+
if not sources:
|
|
70
|
+
print("No valid sources to add.")
|
|
71
|
+
return 1
|
|
72
|
+
_download(cfg, sources)
|
|
73
|
+
return 0
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def cmd_batch(cfg, args):
|
|
77
|
+
path = args.file or str(cfg.magnets_file)
|
|
78
|
+
magnets = read_magnets_file(path)
|
|
79
|
+
if not magnets:
|
|
80
|
+
print(f"No magnet links found in {path}!")
|
|
81
|
+
return 1
|
|
82
|
+
print(f"\nFound {len(magnets)} magnet link(s) in {path} - "
|
|
83
|
+
f"downloading up to {cfg.max_concurrent} at a time.\n")
|
|
84
|
+
sources, _ = resolve_many(magnets, cfg.torrents_dir)
|
|
85
|
+
_download(cfg, sources)
|
|
86
|
+
return 0
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def cmd_resume(cfg, args):
|
|
90
|
+
_download(cfg, sources=[], resume=True)
|
|
91
|
+
return 0
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _fmt_size(n):
|
|
95
|
+
if not n:
|
|
96
|
+
return ""
|
|
97
|
+
size = float(n)
|
|
98
|
+
for unit in ("B", "KB", "MB", "GB", "TB"):
|
|
99
|
+
if size < 1024 or unit == "TB":
|
|
100
|
+
return f"{size:.0f} {unit}" if unit == "B" else f"{size:.1f} {unit}"
|
|
101
|
+
size /= 1024
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _progress_cell(rec):
|
|
105
|
+
if rec.get("status") == "done":
|
|
106
|
+
return "100%"
|
|
107
|
+
prog = rec.get("progress")
|
|
108
|
+
if prog is None:
|
|
109
|
+
return "-"
|
|
110
|
+
pct = f"{prog * 100:.0f}%"
|
|
111
|
+
total = rec.get("total_bytes")
|
|
112
|
+
if total:
|
|
113
|
+
return f"{pct} {_fmt_size(prog * total)}/{_fmt_size(total)}"
|
|
114
|
+
return pct
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def cmd_status(cfg, args):
|
|
118
|
+
cfg.ensure_config_dir()
|
|
119
|
+
state = StateStore(str(cfg.state_file))
|
|
120
|
+
records = state.load()
|
|
121
|
+
if not records:
|
|
122
|
+
print("No tracked torrents.")
|
|
123
|
+
return 0
|
|
124
|
+
try:
|
|
125
|
+
from rich.console import Console
|
|
126
|
+
from rich.table import Table
|
|
127
|
+
table = Table(show_header=True, header_style="bold")
|
|
128
|
+
table.add_column("ID")
|
|
129
|
+
table.add_column("Label", overflow="fold", max_width=44)
|
|
130
|
+
table.add_column("Status")
|
|
131
|
+
table.add_column("Progress")
|
|
132
|
+
table.add_column("Updated")
|
|
133
|
+
table.add_column("Error", overflow="fold")
|
|
134
|
+
for info_hash, rec in records.items():
|
|
135
|
+
table.add_row(
|
|
136
|
+
info_hash[:8], rec.get("label", "?"), rec.get("status", "?"),
|
|
137
|
+
_progress_cell(rec), rec.get("updated_at", ""), rec.get("error") or "",
|
|
138
|
+
)
|
|
139
|
+
Console().print(table)
|
|
140
|
+
except Exception:
|
|
141
|
+
for info_hash, rec in records.items():
|
|
142
|
+
print(f"{info_hash[:8]} {rec.get('status','?'):16} {_progress_cell(rec):8} "
|
|
143
|
+
f"{rec.get('label','?')}"
|
|
144
|
+
+ (f" ({rec.get('error')})" if rec.get("error") else ""))
|
|
145
|
+
print("\nTip: `torrdown resume` shows live speed for active downloads.")
|
|
146
|
+
return 0
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def cmd_remove(cfg, args):
|
|
150
|
+
cfg.ensure_config_dir()
|
|
151
|
+
state = StateStore(str(cfg.state_file))
|
|
152
|
+
records = state.load()
|
|
153
|
+
if not records:
|
|
154
|
+
print("No tracked torrents.")
|
|
155
|
+
return 0
|
|
156
|
+
removed_any = False
|
|
157
|
+
for token in args.ids:
|
|
158
|
+
matches = state.find(token)
|
|
159
|
+
if not matches:
|
|
160
|
+
print(f"No match for {token!r}.")
|
|
161
|
+
continue
|
|
162
|
+
if len(matches) > 1:
|
|
163
|
+
print(f"{token!r} matches {len(matches)} entries - use a longer info-hash prefix.")
|
|
164
|
+
continue
|
|
165
|
+
info_hash = matches[0]
|
|
166
|
+
label = records[info_hash].get("label", info_hash)
|
|
167
|
+
state.remove(info_hash)
|
|
168
|
+
removed_any = True
|
|
169
|
+
print(f"Removed {label}")
|
|
170
|
+
if removed_any:
|
|
171
|
+
state.save()
|
|
172
|
+
print("(download files on disk are left untouched)")
|
|
173
|
+
return 0
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def cmd_clear(cfg, args):
|
|
177
|
+
cfg.ensure_config_dir()
|
|
178
|
+
state = StateStore(str(cfg.state_file))
|
|
179
|
+
state.load()
|
|
180
|
+
count = state.clear(only_terminal=not args.all)
|
|
181
|
+
if count:
|
|
182
|
+
state.save()
|
|
183
|
+
kind = "" if args.all else "completed/failed "
|
|
184
|
+
noun = "entry" if count == 1 else "entries"
|
|
185
|
+
print(f"Cleared {count} {kind}{noun}. Download files are left untouched.")
|
|
186
|
+
return 0
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def cmd_list(cfg, args):
|
|
190
|
+
cfg.ensure_config_dir()
|
|
191
|
+
torrents = list_torrent_files(cfg.torrents_dir)
|
|
192
|
+
if not torrents:
|
|
193
|
+
print(f"No .torrent files found in '{cfg.torrents_dir}'.")
|
|
194
|
+
return 0
|
|
195
|
+
print(f"\nTorrent files in {cfg.torrents_dir}:\n")
|
|
196
|
+
for i, t in enumerate(torrents, 1):
|
|
197
|
+
print(f"{i}. {t}")
|
|
198
|
+
return 0
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def cmd_init(cfg, args):
|
|
202
|
+
cfg.ensure_dirs()
|
|
203
|
+
print("Initialized TorrDown directories:")
|
|
204
|
+
print(f" Config dir: {cfg.config_dir}")
|
|
205
|
+
print(f" Download dir: {cfg.download_dir}")
|
|
206
|
+
print(f" Torrents dir: {cfg.torrents_dir}")
|
|
207
|
+
return 0
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def cmd_config(cfg, args):
|
|
211
|
+
cfg.ensure_config_dir()
|
|
212
|
+
print(f"Config file: {cfg.config_file}")
|
|
213
|
+
print(f"State file: {cfg.state_file}")
|
|
214
|
+
print(f"Magnets file: {cfg.magnets_file}")
|
|
215
|
+
print("\nResolved settings:")
|
|
216
|
+
for key, value in cfg.as_dict().items():
|
|
217
|
+
print(f" {key}: {value}")
|
|
218
|
+
return 0
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
# ---------------------------------------------------------------- interactive
|
|
222
|
+
|
|
223
|
+
def cmd_interactive(cfg, args):
|
|
224
|
+
cfg.ensure_dirs()
|
|
225
|
+
torrents = list_torrent_files(cfg.torrents_dir)
|
|
226
|
+
if torrents:
|
|
227
|
+
print("\nAvailable Torrent Files:\n")
|
|
228
|
+
for i, t in enumerate(torrents, 1):
|
|
229
|
+
print(f"{i}. {t}")
|
|
230
|
+
else:
|
|
231
|
+
print(f"\nNo .torrent files found in '{cfg.torrents_dir}'.")
|
|
232
|
+
|
|
233
|
+
inp = input("\nSelect index (comma separated), paste Magnet link, or type "
|
|
234
|
+
"'m' to batch-download from magnets.txt: ").strip()
|
|
235
|
+
|
|
236
|
+
if inp.lower() == "m":
|
|
237
|
+
return cmd_batch(cfg, argparse.Namespace(file=None))
|
|
238
|
+
|
|
239
|
+
if not inp:
|
|
240
|
+
print("Nothing selected.")
|
|
241
|
+
return 0
|
|
242
|
+
|
|
243
|
+
tokens = [inp] if inp.startswith("magnet:?") else [t.strip() for t in inp.split(",")]
|
|
244
|
+
sources, errors = resolve_many(tokens, cfg.torrents_dir)
|
|
245
|
+
for err in errors:
|
|
246
|
+
print(err)
|
|
247
|
+
if not sources:
|
|
248
|
+
print("No valid selection.")
|
|
249
|
+
return 1
|
|
250
|
+
_download(cfg, sources)
|
|
251
|
+
return 0
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
# -------------------------------------------------------------------- parsing
|
|
255
|
+
|
|
256
|
+
def build_parser():
|
|
257
|
+
parser = argparse.ArgumentParser(
|
|
258
|
+
prog="torrdown",
|
|
259
|
+
description="Single-session libtorrent download CLI.",
|
|
260
|
+
)
|
|
261
|
+
parser.add_argument("--version", action="version",
|
|
262
|
+
version=f"torrdown {__version__}")
|
|
263
|
+
parser.add_argument("--config-dir", help="Override the config/state directory.")
|
|
264
|
+
parser.add_argument("--download-dir", help="Override the download directory.")
|
|
265
|
+
parser.add_argument("--verbose", action="store_true",
|
|
266
|
+
help="Print full tracebacks on error.")
|
|
267
|
+
|
|
268
|
+
sub = parser.add_subparsers(dest="command")
|
|
269
|
+
|
|
270
|
+
p_add = sub.add_parser("add", help="Add magnet link(s) or .torrent path(s) and download.")
|
|
271
|
+
p_add.add_argument("sources", nargs="+", help="Magnet links, .torrent paths, or file indices.")
|
|
272
|
+
p_add.add_argument("--name", help="Label override (only when adding a single source).")
|
|
273
|
+
p_add.set_defaults(func=cmd_add)
|
|
274
|
+
|
|
275
|
+
p_batch = sub.add_parser("batch", help="Download all magnets in a file (default: magnets.txt).")
|
|
276
|
+
p_batch.add_argument("file", nargs="?", help="Path to a magnets file.")
|
|
277
|
+
p_batch.set_defaults(func=cmd_batch)
|
|
278
|
+
|
|
279
|
+
p_resume = sub.add_parser("resume", help="Resume interrupted downloads from saved state.")
|
|
280
|
+
p_resume.set_defaults(func=cmd_resume)
|
|
281
|
+
|
|
282
|
+
p_status = sub.add_parser("status", help="Show tracked torrents and their state.")
|
|
283
|
+
p_status.set_defaults(func=cmd_status)
|
|
284
|
+
|
|
285
|
+
p_remove = sub.add_parser("remove", help="Remove tracked entries by ID (info-hash prefix) or label.")
|
|
286
|
+
p_remove.add_argument("ids", nargs="+", help="Info-hash prefixes (see `status`) or exact labels.")
|
|
287
|
+
p_remove.set_defaults(func=cmd_remove)
|
|
288
|
+
|
|
289
|
+
p_clear = sub.add_parser("clear", help="Clear completed/failed entries (or all with --all).")
|
|
290
|
+
p_clear.add_argument("--all", action="store_true", help="Also clear active entries.")
|
|
291
|
+
p_clear.set_defaults(func=cmd_clear)
|
|
292
|
+
|
|
293
|
+
p_list = sub.add_parser("list", help="List local .torrent files.")
|
|
294
|
+
p_list.set_defaults(func=cmd_list)
|
|
295
|
+
|
|
296
|
+
p_config = sub.add_parser("config", help="Show resolved config and file locations.")
|
|
297
|
+
p_config.set_defaults(func=cmd_config)
|
|
298
|
+
|
|
299
|
+
p_init = sub.add_parser("init", help="Create the config/download/torrents directories.")
|
|
300
|
+
p_init.set_defaults(func=cmd_init)
|
|
301
|
+
|
|
302
|
+
return parser
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def main(argv=None):
|
|
306
|
+
parser = build_parser()
|
|
307
|
+
args = parser.parse_args(argv)
|
|
308
|
+
|
|
309
|
+
cfg = Config(
|
|
310
|
+
config_dir=args.config_dir,
|
|
311
|
+
overrides={"download_dir": args.download_dir},
|
|
312
|
+
)
|
|
313
|
+
|
|
314
|
+
func = getattr(args, "func", None) or cmd_interactive
|
|
315
|
+
try:
|
|
316
|
+
return func(cfg, args)
|
|
317
|
+
except SourceError as exc:
|
|
318
|
+
print(f"Error: {exc}")
|
|
319
|
+
return 1
|
|
320
|
+
except KeyboardInterrupt:
|
|
321
|
+
return 130
|
|
322
|
+
except Exception as exc:
|
|
323
|
+
if getattr(args, "verbose", False):
|
|
324
|
+
raise
|
|
325
|
+
print(f"Error: {exc}")
|
|
326
|
+
return 1
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
if __name__ == "__main__":
|
|
330
|
+
sys.exit(main())
|
torrdown/config.py
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Resolves TorrDown's paths and tunables.
|
|
3
|
+
|
|
4
|
+
Precedence, highest first: explicit CLI overrides > TORRDOWN_* env vars >
|
|
5
|
+
config.json > built-in defaults. Config and state live in a per-user config
|
|
6
|
+
dir (not the working directory), so the tool behaves the same regardless of
|
|
7
|
+
where it's launched from."""
|
|
8
|
+
import os
|
|
9
|
+
import sys
|
|
10
|
+
import json
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
DEFAULTS = {
|
|
14
|
+
"download_dir": None, # resolved lazily to ~/Downloads/torrdown
|
|
15
|
+
"torrents_dir": None, # resolved lazily to download_dir (drop .torrent files there)
|
|
16
|
+
"port": 6881,
|
|
17
|
+
"max_concurrent": 3,
|
|
18
|
+
"metadata_timeout": 180,
|
|
19
|
+
"checkpoint_interval": 30,
|
|
20
|
+
"heartbeat_delay": 15,
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
# Keys the user may override via TORRDOWN_<KEY> env vars, with their types.
|
|
24
|
+
_ENV_TYPES = {
|
|
25
|
+
"download_dir": str,
|
|
26
|
+
"torrents_dir": str,
|
|
27
|
+
"port": int,
|
|
28
|
+
"max_concurrent": int,
|
|
29
|
+
"metadata_timeout": int,
|
|
30
|
+
"checkpoint_interval": int,
|
|
31
|
+
"heartbeat_delay": int,
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def default_config_dir():
|
|
36
|
+
"""Per-user config/state dir. Honors XDG on Linux/macOS and APPDATA on
|
|
37
|
+
Windows, falling back to ~/.config/torrdown."""
|
|
38
|
+
if sys.platform == "win32":
|
|
39
|
+
base = os.environ.get("APPDATA")
|
|
40
|
+
if base:
|
|
41
|
+
return Path(base) / "torrdown"
|
|
42
|
+
xdg = os.environ.get("XDG_CONFIG_HOME")
|
|
43
|
+
if xdg:
|
|
44
|
+
return Path(xdg) / "torrdown"
|
|
45
|
+
return Path.home() / ".config" / "torrdown"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _coerce(key, value):
|
|
49
|
+
caster = _ENV_TYPES.get(key, str)
|
|
50
|
+
return caster(value)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class Config:
|
|
54
|
+
def __init__(self, config_dir=None, overrides=None):
|
|
55
|
+
self.config_dir = Path(config_dir) if config_dir else default_config_dir()
|
|
56
|
+
self._overrides = {k: v for k, v in (overrides or {}).items() if v is not None}
|
|
57
|
+
self._file_values = {}
|
|
58
|
+
self._resolved = None
|
|
59
|
+
|
|
60
|
+
# --- paths derived from the config dir ---
|
|
61
|
+
@property
|
|
62
|
+
def config_file(self):
|
|
63
|
+
return self.config_dir / "config.json"
|
|
64
|
+
|
|
65
|
+
@property
|
|
66
|
+
def state_file(self):
|
|
67
|
+
return self.config_dir / "state.json"
|
|
68
|
+
|
|
69
|
+
@property
|
|
70
|
+
def magnets_file(self):
|
|
71
|
+
return self.config_dir / "magnets.txt"
|
|
72
|
+
|
|
73
|
+
def ensure_config_dir(self):
|
|
74
|
+
"""Create the config dir and a default config.json on first run.
|
|
75
|
+
Cheap - used by read-only commands (status/list/config) that shouldn't
|
|
76
|
+
create download directories as a side effect."""
|
|
77
|
+
self.config_dir.mkdir(parents=True, exist_ok=True)
|
|
78
|
+
if not self.config_file.exists():
|
|
79
|
+
self._write_default_config()
|
|
80
|
+
|
|
81
|
+
def ensure_dirs(self):
|
|
82
|
+
"""ensure_config_dir plus the resolved download/torrents dirs - used by
|
|
83
|
+
commands that actually download."""
|
|
84
|
+
self.ensure_config_dir()
|
|
85
|
+
Path(self.download_dir).mkdir(parents=True, exist_ok=True)
|
|
86
|
+
Path(self.torrents_dir).mkdir(parents=True, exist_ok=True)
|
|
87
|
+
|
|
88
|
+
def _write_default_config(self):
|
|
89
|
+
template = {
|
|
90
|
+
"_comment": "TorrDown config. Delete a key to fall back to its default. "
|
|
91
|
+
"CLI flags and TORRDOWN_* env vars override these.",
|
|
92
|
+
"download_dir": str(Path.home() / "Downloads" / "torrdown"),
|
|
93
|
+
"torrents_dir": "",
|
|
94
|
+
"port": DEFAULTS["port"],
|
|
95
|
+
"max_concurrent": DEFAULTS["max_concurrent"],
|
|
96
|
+
"metadata_timeout": DEFAULTS["metadata_timeout"],
|
|
97
|
+
"checkpoint_interval": DEFAULTS["checkpoint_interval"],
|
|
98
|
+
"heartbeat_delay": DEFAULTS["heartbeat_delay"],
|
|
99
|
+
}
|
|
100
|
+
tmp = self.config_file.with_suffix(".json.tmp")
|
|
101
|
+
tmp.write_text(json.dumps(template, indent=2))
|
|
102
|
+
os.replace(tmp, self.config_file)
|
|
103
|
+
|
|
104
|
+
def _load_file(self):
|
|
105
|
+
if not self.config_file.exists():
|
|
106
|
+
return {}
|
|
107
|
+
try:
|
|
108
|
+
data = json.loads(self.config_file.read_text())
|
|
109
|
+
except (json.JSONDecodeError, OSError, UnicodeDecodeError) as exc:
|
|
110
|
+
print(f"Warning: {self.config_file} is unreadable ({exc}); using defaults.")
|
|
111
|
+
return {}
|
|
112
|
+
return {k: v for k, v in data.items()
|
|
113
|
+
if k in DEFAULTS and v not in (None, "")}
|
|
114
|
+
|
|
115
|
+
def resolve(self):
|
|
116
|
+
"""Merge the layers once and cache the result."""
|
|
117
|
+
if self._resolved is not None:
|
|
118
|
+
return self._resolved
|
|
119
|
+
self._file_values = self._load_file()
|
|
120
|
+
|
|
121
|
+
merged = dict(DEFAULTS)
|
|
122
|
+
merged.update(self._file_values)
|
|
123
|
+
for key in DEFAULTS:
|
|
124
|
+
env_val = os.environ.get(f"TORRDOWN_{key.upper()}")
|
|
125
|
+
if env_val:
|
|
126
|
+
merged[key] = _coerce(key, env_val)
|
|
127
|
+
merged.update(self._overrides)
|
|
128
|
+
|
|
129
|
+
# Lazy path defaults, applied only if still unset after all layers.
|
|
130
|
+
if not merged.get("download_dir"):
|
|
131
|
+
merged["download_dir"] = str(Path.home() / "Downloads" / "torrdown")
|
|
132
|
+
if not merged.get("torrents_dir"):
|
|
133
|
+
# Dedicated subfolder for source .torrent files, kept separate from
|
|
134
|
+
# downloaded content so the interactive menu / `list` stays clean.
|
|
135
|
+
merged["torrents_dir"] = str(Path(merged["download_dir"]) / "torrents")
|
|
136
|
+
merged["download_dir"] = str(Path(merged["download_dir"]).expanduser())
|
|
137
|
+
merged["torrents_dir"] = str(Path(merged["torrents_dir"]).expanduser())
|
|
138
|
+
|
|
139
|
+
self._resolved = merged
|
|
140
|
+
return merged
|
|
141
|
+
|
|
142
|
+
def __getattr__(self, name):
|
|
143
|
+
# Expose resolved settings (download_dir, port, ...) as attributes.
|
|
144
|
+
if name in DEFAULTS:
|
|
145
|
+
return self.resolve()[name]
|
|
146
|
+
raise AttributeError(name)
|
|
147
|
+
|
|
148
|
+
def as_dict(self):
|
|
149
|
+
return dict(self.resolve())
|
torrdown/engine.py
ADDED
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Drives a single shared libtorrent session for all concurrent downloads,
|
|
3
|
+
replacing torrentp's one-session-per-torrent design. Metadata resolution and
|
|
4
|
+
per-torrent status are event/poll-driven from the main thread only - no
|
|
5
|
+
threads, no per-torrent ports.
|
|
6
|
+
|
|
7
|
+
Note: libtorrent's session.get_torrent_status(predicate) is NOT used here -
|
|
8
|
+
it invokes the Python predicate from a libtorrent-internal worker thread,
|
|
9
|
+
which segfaults when that callback allocates a Python object outside the
|
|
10
|
+
GIL (confirmed via a real crash report: the fault is in
|
|
11
|
+
get_torrent_status -> wrap_pred -> PyType_GenericAlloc). Per-handle
|
|
12
|
+
`handle.status()` calls from this thread are the safe equivalent for the
|
|
13
|
+
small number of torrents this tool handles at once.
|
|
14
|
+
"""
|
|
15
|
+
import time
|
|
16
|
+
import libtorrent as lt
|
|
17
|
+
from dataclasses import dataclass
|
|
18
|
+
from rich.progress import (
|
|
19
|
+
BarColumn,
|
|
20
|
+
DownloadColumn,
|
|
21
|
+
Progress,
|
|
22
|
+
TaskProgressColumn,
|
|
23
|
+
TextColumn,
|
|
24
|
+
TimeRemainingColumn,
|
|
25
|
+
TransferSpeedColumn,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
from .state import StateStore
|
|
29
|
+
|
|
30
|
+
ALERT_MASK = (lt.alert.category_t.status_notification
|
|
31
|
+
| lt.alert.category_t.error_notification)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass
|
|
35
|
+
class ActiveTorrent:
|
|
36
|
+
handle: object
|
|
37
|
+
label: str
|
|
38
|
+
source: str
|
|
39
|
+
info_hash: str
|
|
40
|
+
added_at: float
|
|
41
|
+
task_id: object = None
|
|
42
|
+
metadata_seen: bool = False
|
|
43
|
+
heartbeat_printed: bool = False
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _build_params(source, save_path):
|
|
47
|
+
if source.startswith("magnet:"):
|
|
48
|
+
params = lt.parse_magnet_uri(source)
|
|
49
|
+
else:
|
|
50
|
+
params = lt.add_torrent_params()
|
|
51
|
+
params.ti = lt.torrent_info(source)
|
|
52
|
+
params.save_path = save_path
|
|
53
|
+
return params
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def info_hash_of(source):
|
|
57
|
+
if source.startswith("magnet:"):
|
|
58
|
+
return str(lt.parse_magnet_uri(source).info_hashes.v1)
|
|
59
|
+
return str(lt.torrent_info(source).info_hash())
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class TorrentEngine:
|
|
63
|
+
def __init__(self, target_dir, state_store, port=6881, max_concurrent=3,
|
|
64
|
+
metadata_timeout=180, checkpoint_interval=30, heartbeat_delay=15,
|
|
65
|
+
on_result=None):
|
|
66
|
+
self._target_dir = target_dir
|
|
67
|
+
self._state = state_store
|
|
68
|
+
self._metadata_timeout = metadata_timeout
|
|
69
|
+
self._checkpoint_interval = checkpoint_interval
|
|
70
|
+
self._heartbeat_delay = heartbeat_delay
|
|
71
|
+
self._max_concurrent_metadata = max_concurrent
|
|
72
|
+
self._on_result = on_result
|
|
73
|
+
self._session = lt.session({
|
|
74
|
+
"listen_interfaces": f"0.0.0.0:{port}",
|
|
75
|
+
"alert_mask": ALERT_MASK,
|
|
76
|
+
"enable_dht": True,
|
|
77
|
+
# Once metadata is known, libtorrent shares bandwidth across
|
|
78
|
+
# active downloads well on its own - this only needs to be
|
|
79
|
+
# generous enough not to become a second bottleneck. The real
|
|
80
|
+
# concurrency limit is applied at the pre-metadata queueing
|
|
81
|
+
# stage below, since that's where torrents actually starve each
|
|
82
|
+
# other of peer connections (confirmed empirically: torrents
|
|
83
|
+
# added simultaneously can sit at 0 peers for 30s+ even when
|
|
84
|
+
# nominally "active", regardless of queue position).
|
|
85
|
+
"active_downloads": max(20, max_concurrent * 4),
|
|
86
|
+
})
|
|
87
|
+
self._active = {} # info_hash -> ActiveTorrent
|
|
88
|
+
self._pending = [] # list of (source, label) not yet added to session
|
|
89
|
+
self._source_by_hash = {} # info_hash -> source, kept past completion
|
|
90
|
+
self._results = {} # info_hash -> (status, reason)
|
|
91
|
+
self._shutdown_requested = False
|
|
92
|
+
self._last_checkpoint = time.monotonic()
|
|
93
|
+
|
|
94
|
+
def request_shutdown(self):
|
|
95
|
+
self._shutdown_requested = True
|
|
96
|
+
|
|
97
|
+
def has_work(self):
|
|
98
|
+
"""True if any torrent is active or queued - lets callers skip the
|
|
99
|
+
run loop when there's nothing to do."""
|
|
100
|
+
return bool(self._active or self._pending)
|
|
101
|
+
|
|
102
|
+
def _pending_metadata_count(self):
|
|
103
|
+
return sum(1 for a in self._active.values() if not a.metadata_seen)
|
|
104
|
+
|
|
105
|
+
def add_source(self, source, label):
|
|
106
|
+
info_hash = info_hash_of(source)
|
|
107
|
+
if info_hash in self._active or any(s == source for s, _ in self._pending):
|
|
108
|
+
print(f"Skipping duplicate - {label} (already active or queued)")
|
|
109
|
+
return None
|
|
110
|
+
# Only let up to max_concurrent torrents compete for metadata/peer
|
|
111
|
+
# connections at once - anything beyond that waits in self._pending
|
|
112
|
+
# until a slot frees up, so its metadata-timeout clock doesn't start
|
|
113
|
+
# ticking until it's actually had a real chance.
|
|
114
|
+
if self._pending_metadata_count() >= self._max_concurrent_metadata:
|
|
115
|
+
self._pending.append((source, label))
|
|
116
|
+
print(f"Queued - {label} (waiting for a metadata slot)")
|
|
117
|
+
return None
|
|
118
|
+
return self._add_now(source, label)
|
|
119
|
+
|
|
120
|
+
def _add_now(self, source, label, resume_b64=None):
|
|
121
|
+
info_hash = info_hash_of(source)
|
|
122
|
+
try:
|
|
123
|
+
if resume_b64:
|
|
124
|
+
params = lt.read_resume_data(StateStore.decode_resume_data(resume_b64))
|
|
125
|
+
else:
|
|
126
|
+
params = _build_params(source, self._target_dir)
|
|
127
|
+
handle = self._session.add_torrent(params)
|
|
128
|
+
except Exception as exc:
|
|
129
|
+
print(f"Failed to add {label}: {exc}")
|
|
130
|
+
self._record_result(info_hash, source, "failed", str(exc))
|
|
131
|
+
return None
|
|
132
|
+
# .torrent files and resume-data blobs both carry full metadata
|
|
133
|
+
# already - only a bare magnet with no resume data needs to actually
|
|
134
|
+
# wait for metadata_received_alert.
|
|
135
|
+
active = ActiveTorrent(handle=handle, label=label, source=source,
|
|
136
|
+
info_hash=info_hash, added_at=time.monotonic(),
|
|
137
|
+
metadata_seen=bool(resume_b64) or not source.startswith("magnet:"))
|
|
138
|
+
self._active[info_hash] = active
|
|
139
|
+
self._source_by_hash[info_hash] = source
|
|
140
|
+
self._state.upsert(info_hash, source=source, label=label,
|
|
141
|
+
save_path=self._target_dir, status="active",
|
|
142
|
+
resume_data=resume_b64, error=None)
|
|
143
|
+
return active
|
|
144
|
+
|
|
145
|
+
def _promote_pending(self):
|
|
146
|
+
while self._pending and self._pending_metadata_count() < self._max_concurrent_metadata:
|
|
147
|
+
source, label = self._pending.pop(0)
|
|
148
|
+
self._add_now(source, label)
|
|
149
|
+
|
|
150
|
+
def _record_result(self, info_hash, source, status, reason):
|
|
151
|
+
"""Persists a terminal outcome immediately - to state.json right
|
|
152
|
+
away, and via on_result (for magnets.txt archiving) if provided -
|
|
153
|
+
rather than waiting for the whole batch to finish."""
|
|
154
|
+
self._results[info_hash] = (status, reason)
|
|
155
|
+
self._source_by_hash[info_hash] = source
|
|
156
|
+
state_status = "skipped_timeout" if reason == "metadata timeout" else status
|
|
157
|
+
self._state.upsert(info_hash, status=state_status, error=reason)
|
|
158
|
+
self._state.save()
|
|
159
|
+
if self._on_result:
|
|
160
|
+
self._on_result(source, status, reason)
|
|
161
|
+
|
|
162
|
+
def resume_from_state(self):
|
|
163
|
+
for info_hash, record in self._state.active_records().items():
|
|
164
|
+
source = record.get("source")
|
|
165
|
+
label = record.get("label", info_hash[:8])
|
|
166
|
+
resume_b64 = record.get("resume_data")
|
|
167
|
+
# Resume data (or a non-magnet source) already carries metadata,
|
|
168
|
+
# so it can skip straight in regardless of the metadata-slot
|
|
169
|
+
# gate. A bare magnet with no resume data yet still needs to
|
|
170
|
+
# fetch metadata, so it competes for a slot exactly like a fresh
|
|
171
|
+
# request - otherwise resumed torrents could reintroduce the
|
|
172
|
+
# same starvation the gate exists to prevent.
|
|
173
|
+
if resume_b64 or not source.startswith("magnet:"):
|
|
174
|
+
active = self._add_now(source, label, resume_b64=resume_b64)
|
|
175
|
+
elif self._pending_metadata_count() >= self._max_concurrent_metadata:
|
|
176
|
+
self._pending.append((source, label))
|
|
177
|
+
print(f"Queued (resumed) - {label} (waiting for a metadata slot)")
|
|
178
|
+
continue
|
|
179
|
+
else:
|
|
180
|
+
active = self._add_now(source, label)
|
|
181
|
+
if active:
|
|
182
|
+
print(f"Resumed - {label}")
|
|
183
|
+
|
|
184
|
+
def get_results_by_source(self):
|
|
185
|
+
return {
|
|
186
|
+
self._source_by_hash[h]: outcome
|
|
187
|
+
for h, outcome in self._results.items()
|
|
188
|
+
if h in self._source_by_hash
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
def run(self):
|
|
192
|
+
progress = Progress(
|
|
193
|
+
TextColumn("[bold blue]{task.description}", justify="right"),
|
|
194
|
+
BarColumn(),
|
|
195
|
+
TaskProgressColumn(),
|
|
196
|
+
DownloadColumn(),
|
|
197
|
+
TransferSpeedColumn(),
|
|
198
|
+
TimeRemainingColumn(compact=True, elapsed_when_finished=True),
|
|
199
|
+
)
|
|
200
|
+
with progress:
|
|
201
|
+
while (self._active or self._pending) and not self._shutdown_requested:
|
|
202
|
+
self._session.wait_for_alert(500)
|
|
203
|
+
for alert in self._session.pop_alerts():
|
|
204
|
+
self._handle_alert(alert, progress)
|
|
205
|
+
self._check_deadlines()
|
|
206
|
+
self._update_progress(progress)
|
|
207
|
+
self._promote_pending()
|
|
208
|
+
self._maybe_checkpoint()
|
|
209
|
+
|
|
210
|
+
if self._shutdown_requested:
|
|
211
|
+
self._graceful_shutdown()
|
|
212
|
+
|
|
213
|
+
self._state.save()
|
|
214
|
+
|
|
215
|
+
def _handle_alert(self, alert, progress):
|
|
216
|
+
handle = getattr(alert, "handle", None)
|
|
217
|
+
if handle is None:
|
|
218
|
+
return
|
|
219
|
+
info_hash = str(handle.info_hash())
|
|
220
|
+
active = self._active.get(info_hash)
|
|
221
|
+
if isinstance(alert, lt.metadata_received_alert):
|
|
222
|
+
if active:
|
|
223
|
+
active.metadata_seen = True
|
|
224
|
+
elif isinstance(alert, lt.torrent_finished_alert):
|
|
225
|
+
if active:
|
|
226
|
+
self._finish(info_hash, "done", None, progress)
|
|
227
|
+
elif isinstance(alert, lt.torrent_error_alert):
|
|
228
|
+
if active:
|
|
229
|
+
try:
|
|
230
|
+
reason = alert.error.message() if alert.error else alert.message()
|
|
231
|
+
except Exception:
|
|
232
|
+
reason = alert.message()
|
|
233
|
+
self._finish(info_hash, "failed", reason, progress)
|
|
234
|
+
elif isinstance(alert, lt.save_resume_data_alert):
|
|
235
|
+
resume_bytes = lt.write_resume_data_buf(alert.params)
|
|
236
|
+
self._state.upsert(info_hash, resume_data=StateStore.encode_resume_data(resume_bytes))
|
|
237
|
+
elif isinstance(alert, lt.save_resume_data_failed_alert):
|
|
238
|
+
pass # best-effort; not fatal
|
|
239
|
+
|
|
240
|
+
def _finish(self, info_hash, status, reason, progress):
|
|
241
|
+
active = self._active.pop(info_hash, None)
|
|
242
|
+
if active is None:
|
|
243
|
+
return
|
|
244
|
+
if active.task_id is not None:
|
|
245
|
+
try:
|
|
246
|
+
st = active.handle.status()
|
|
247
|
+
progress.update(active.task_id, completed=st.total_wanted, total=st.total_wanted)
|
|
248
|
+
except Exception:
|
|
249
|
+
pass
|
|
250
|
+
if status == "done":
|
|
251
|
+
print(f"Completed - {active.label}")
|
|
252
|
+
else:
|
|
253
|
+
print(f"Failed - {active.label}: {reason}")
|
|
254
|
+
self._record_result(info_hash, active.source, status, reason)
|
|
255
|
+
try:
|
|
256
|
+
self._session.remove_torrent(active.handle)
|
|
257
|
+
except Exception:
|
|
258
|
+
pass
|
|
259
|
+
|
|
260
|
+
def _check_deadlines(self):
|
|
261
|
+
now = time.monotonic()
|
|
262
|
+
for info_hash, active in list(self._active.items()):
|
|
263
|
+
if active.metadata_seen:
|
|
264
|
+
continue
|
|
265
|
+
elapsed = now - active.added_at
|
|
266
|
+
if elapsed > self._metadata_timeout:
|
|
267
|
+
print(f"Timed out - {active.label} (no metadata after {self._metadata_timeout}s)")
|
|
268
|
+
self._active.pop(info_hash, None)
|
|
269
|
+
self._record_result(info_hash, active.source, "failed", "metadata timeout")
|
|
270
|
+
try:
|
|
271
|
+
self._session.remove_torrent(active.handle)
|
|
272
|
+
except Exception:
|
|
273
|
+
pass
|
|
274
|
+
elif elapsed > self._heartbeat_delay and not active.heartbeat_printed:
|
|
275
|
+
active.heartbeat_printed = True
|
|
276
|
+
print(f" ...still working on {active.label} - normal while "
|
|
277
|
+
f"resolving magnet metadata or waiting on peers")
|
|
278
|
+
|
|
279
|
+
def _update_progress(self, progress):
|
|
280
|
+
for info_hash, active in list(self._active.items()):
|
|
281
|
+
if not active.metadata_seen:
|
|
282
|
+
continue
|
|
283
|
+
if active.task_id is None:
|
|
284
|
+
st = active.handle.status()
|
|
285
|
+
active.task_id = progress.add_task(st.name, total=st.total_wanted)
|
|
286
|
+
st = active.handle.status()
|
|
287
|
+
progress.update(active.task_id, completed=st.total_done, total=st.total_wanted)
|
|
288
|
+
# Record progress into the in-memory state record so `torrdown
|
|
289
|
+
# status` can show how far along things are. Persisted on the
|
|
290
|
+
# existing checkpoint cadence - no extra disk writes here.
|
|
291
|
+
self._state.upsert(
|
|
292
|
+
info_hash,
|
|
293
|
+
progress=(st.total_done / st.total_wanted) if st.total_wanted else 0.0,
|
|
294
|
+
total_bytes=st.total_wanted,
|
|
295
|
+
)
|
|
296
|
+
if st.is_seeding:
|
|
297
|
+
# Covers torrents already fully-downloaded at add time, which
|
|
298
|
+
# never fire torrent_finished_alert since no transition occurs.
|
|
299
|
+
self._finish(info_hash, "done", None, progress)
|
|
300
|
+
|
|
301
|
+
def _maybe_checkpoint(self):
|
|
302
|
+
now = time.monotonic()
|
|
303
|
+
if now - self._last_checkpoint < self._checkpoint_interval:
|
|
304
|
+
return
|
|
305
|
+
self._last_checkpoint = now
|
|
306
|
+
for active in self._active.values():
|
|
307
|
+
if active.handle.need_save_resume_data():
|
|
308
|
+
active.handle.save_resume_data()
|
|
309
|
+
self._state.save()
|
|
310
|
+
|
|
311
|
+
def _graceful_shutdown(self):
|
|
312
|
+
print("Shutting down - saving progress...")
|
|
313
|
+
self._session.pause()
|
|
314
|
+
pending = set()
|
|
315
|
+
for active in self._active.values():
|
|
316
|
+
if active.handle.need_save_resume_data():
|
|
317
|
+
active.handle.save_resume_data()
|
|
318
|
+
pending.add(active.info_hash)
|
|
319
|
+
deadline = time.monotonic() + 10
|
|
320
|
+
while pending and time.monotonic() < deadline:
|
|
321
|
+
self._session.wait_for_alert(200)
|
|
322
|
+
for alert in self._session.pop_alerts():
|
|
323
|
+
if isinstance(alert, (lt.save_resume_data_alert, lt.save_resume_data_failed_alert)):
|
|
324
|
+
handle = getattr(alert, "handle", None)
|
|
325
|
+
if handle is None:
|
|
326
|
+
continue
|
|
327
|
+
ih = str(handle.info_hash())
|
|
328
|
+
if isinstance(alert, lt.save_resume_data_alert):
|
|
329
|
+
resume_bytes = lt.write_resume_data_buf(alert.params)
|
|
330
|
+
self._state.upsert(ih, resume_data=StateStore.encode_resume_data(resume_bytes))
|
|
331
|
+
pending.discard(ih)
|
|
332
|
+
|
|
333
|
+
def close(self):
|
|
334
|
+
del self._session
|
torrdown/magnets.py
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Reads magnet links from a file and archives completed/failed ones in
|
|
3
|
+
place (commented out with the outcome and date) so re-running the tool
|
|
4
|
+
doesn't redownload the same list. No auto-retry - a dead/zero-seed magnet
|
|
5
|
+
shouldn't loop forever; uncomment the line manually to retry it."""
|
|
6
|
+
import os
|
|
7
|
+
import time
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def read_magnets_file(path):
|
|
11
|
+
if not os.path.exists(path):
|
|
12
|
+
return []
|
|
13
|
+
with open(path) as f:
|
|
14
|
+
return [
|
|
15
|
+
line.strip() for line in f
|
|
16
|
+
if line.strip() and not line.strip().startswith("#")
|
|
17
|
+
]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def archive_results(path, results):
|
|
21
|
+
"""results: dict of {original magnet line: (status, reason)}, where
|
|
22
|
+
status is "done" or "failed" (skipped-on-timeout counts as failed).
|
|
23
|
+
Lines with no entry in results (still active, or not a tracked magnet)
|
|
24
|
+
are left untouched."""
|
|
25
|
+
if not results or not os.path.exists(path):
|
|
26
|
+
return
|
|
27
|
+
|
|
28
|
+
date = time.strftime("%Y-%m-%d")
|
|
29
|
+
with open(path) as f:
|
|
30
|
+
lines = f.readlines()
|
|
31
|
+
|
|
32
|
+
new_lines = []
|
|
33
|
+
for line in lines:
|
|
34
|
+
stripped = line.strip()
|
|
35
|
+
outcome = results.get(stripped)
|
|
36
|
+
if outcome is None:
|
|
37
|
+
new_lines.append(line)
|
|
38
|
+
continue
|
|
39
|
+
status, reason = outcome
|
|
40
|
+
if status == "done":
|
|
41
|
+
new_lines.append(f"# [done {date}] {stripped}\n")
|
|
42
|
+
else:
|
|
43
|
+
new_lines.append(f"# [failed {date}: {reason}] {stripped}\n")
|
|
44
|
+
|
|
45
|
+
with open(path, "w") as f:
|
|
46
|
+
f.writelines(new_lines)
|
torrdown/sources.py
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Turns user-provided tokens into concrete download sources.
|
|
3
|
+
|
|
4
|
+
A "source" is either a magnet URI or a path to a local .torrent file - the two
|
|
5
|
+
things engine.TorrentEngine.add_source knows how to consume. This module is the
|
|
6
|
+
single place that decides what a token means, so the CLI, interactive mode, and
|
|
7
|
+
(future) fetch adapters all resolve inputs the same way.
|
|
8
|
+
|
|
9
|
+
Future extension point: a fetch adapter (e.g. Internet Archive search) would
|
|
10
|
+
resolve a `search:<query>` token here into one or more magnet sources, without
|
|
11
|
+
the engine or CLI needing to change. See ROADMAP.md."""
|
|
12
|
+
import os
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class SourceError(ValueError):
|
|
16
|
+
"""Raised when a token can't be resolved to a usable source."""
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def list_torrent_files(torrents_dir):
|
|
20
|
+
"""Local .torrent files available for index selection, sorted for stable
|
|
21
|
+
numbering."""
|
|
22
|
+
if not os.path.isdir(torrents_dir):
|
|
23
|
+
return []
|
|
24
|
+
return sorted(f for f in os.listdir(torrents_dir) if f.endswith(".torrent"))
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def resolve_token(token, torrents_dir):
|
|
28
|
+
"""Resolve a single token to a list of (source, label) tuples.
|
|
29
|
+
|
|
30
|
+
- magnet:?... -> that magnet
|
|
31
|
+
- path to a .torrent -> that file
|
|
32
|
+
- bare integer "N" -> the Nth local .torrent file (1-based)
|
|
33
|
+
"""
|
|
34
|
+
token = token.strip()
|
|
35
|
+
if not token:
|
|
36
|
+
return []
|
|
37
|
+
|
|
38
|
+
if token.startswith("magnet:?"):
|
|
39
|
+
return [(token, _magnet_label(token))]
|
|
40
|
+
|
|
41
|
+
if token.endswith(".torrent"):
|
|
42
|
+
path = os.path.expanduser(token)
|
|
43
|
+
if not os.path.isfile(path):
|
|
44
|
+
raise SourceError(f"No such .torrent file: {token}")
|
|
45
|
+
return [(path, os.path.basename(path))]
|
|
46
|
+
|
|
47
|
+
if token.isdigit():
|
|
48
|
+
torrents = list_torrent_files(torrents_dir)
|
|
49
|
+
idx = int(token)
|
|
50
|
+
if 1 <= idx <= len(torrents):
|
|
51
|
+
name = torrents[idx - 1]
|
|
52
|
+
return [(os.path.join(torrents_dir, name), name)]
|
|
53
|
+
raise SourceError(f"Invalid index: {idx} (have {len(torrents)} file(s))")
|
|
54
|
+
|
|
55
|
+
raise SourceError(
|
|
56
|
+
f"Unrecognized source: {token!r} "
|
|
57
|
+
"(expected a magnet link, a .torrent path, or a file index)"
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def resolve_many(tokens, torrents_dir):
|
|
62
|
+
"""Resolve several tokens, collecting per-token errors instead of aborting
|
|
63
|
+
the whole batch. Returns (sources, errors)."""
|
|
64
|
+
sources, errors = [], []
|
|
65
|
+
for token in tokens:
|
|
66
|
+
try:
|
|
67
|
+
sources.extend(resolve_token(token, torrents_dir))
|
|
68
|
+
except SourceError as exc:
|
|
69
|
+
errors.append(str(exc))
|
|
70
|
+
return sources, errors
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _magnet_label(magnet):
|
|
74
|
+
"""Human-friendly label from a magnet's dn= field, if present."""
|
|
75
|
+
marker = "&dn="
|
|
76
|
+
if marker in magnet:
|
|
77
|
+
from urllib.parse import unquote
|
|
78
|
+
raw = magnet.split(marker, 1)[1].split("&", 1)[0]
|
|
79
|
+
return unquote(raw)
|
|
80
|
+
return "magnet"
|
torrdown/state.py
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Persists torrent download state to a JSON file, keyed by info-hash, so
|
|
3
|
+
downloads can resume after Ctrl+C, a crash, or a restart instead of
|
|
4
|
+
starting over."""
|
|
5
|
+
import os
|
|
6
|
+
import json
|
|
7
|
+
import time
|
|
8
|
+
import base64
|
|
9
|
+
|
|
10
|
+
# Statuses that mean a torrent is finished with (in some way) and safe to clear.
|
|
11
|
+
TERMINAL_STATUSES = frozenset({"done", "failed", "skipped_timeout"})
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class StateStore:
|
|
15
|
+
def __init__(self, path):
|
|
16
|
+
self._path = path
|
|
17
|
+
self._records = {}
|
|
18
|
+
|
|
19
|
+
def load(self):
|
|
20
|
+
if not os.path.exists(self._path):
|
|
21
|
+
self._records = {}
|
|
22
|
+
return self._records
|
|
23
|
+
try:
|
|
24
|
+
with open(self._path) as f:
|
|
25
|
+
self._records = json.load(f)
|
|
26
|
+
except (json.JSONDecodeError, OSError, UnicodeDecodeError) as exc:
|
|
27
|
+
bad_path = f"{self._path}.bad-{int(time.time())}"
|
|
28
|
+
try:
|
|
29
|
+
os.replace(self._path, bad_path)
|
|
30
|
+
print(f"Warning: {self._path} was corrupt ({exc}); moved "
|
|
31
|
+
f"aside to {bad_path} and starting fresh.")
|
|
32
|
+
except OSError:
|
|
33
|
+
print(f"Warning: {self._path} was corrupt ({exc}); starting fresh.")
|
|
34
|
+
self._records = {}
|
|
35
|
+
return self._records
|
|
36
|
+
|
|
37
|
+
def save(self):
|
|
38
|
+
tmp_path = f"{self._path}.tmp"
|
|
39
|
+
with open(tmp_path, "w") as f:
|
|
40
|
+
json.dump(self._records, f, indent=2)
|
|
41
|
+
os.replace(tmp_path, self._path)
|
|
42
|
+
|
|
43
|
+
def upsert(self, info_hash, **fields):
|
|
44
|
+
record = self._records.setdefault(info_hash, {})
|
|
45
|
+
record.update(fields)
|
|
46
|
+
record["updated_at"] = time.strftime("%Y-%m-%d %H:%M:%S")
|
|
47
|
+
return record
|
|
48
|
+
|
|
49
|
+
def get(self, info_hash):
|
|
50
|
+
return self._records.get(info_hash)
|
|
51
|
+
|
|
52
|
+
def all_records(self):
|
|
53
|
+
return dict(self._records)
|
|
54
|
+
|
|
55
|
+
def active_records(self):
|
|
56
|
+
return {h: r for h, r in self._records.items() if r.get("status") == "active"}
|
|
57
|
+
|
|
58
|
+
def remove(self, info_hash):
|
|
59
|
+
self._records.pop(info_hash, None)
|
|
60
|
+
|
|
61
|
+
def find(self, token):
|
|
62
|
+
"""Match records by exact label first, then by info-hash prefix.
|
|
63
|
+
Returns a list of matching info-hashes (empty if none)."""
|
|
64
|
+
token = token.strip()
|
|
65
|
+
exact = [h for h, r in self._records.items() if r.get("label") == token]
|
|
66
|
+
if exact:
|
|
67
|
+
return exact
|
|
68
|
+
low = token.lower()
|
|
69
|
+
return [h for h in self._records if h.lower().startswith(low)]
|
|
70
|
+
|
|
71
|
+
def clear(self, only_terminal=True):
|
|
72
|
+
"""Drop tracking records. By default only completed/failed/timed-out
|
|
73
|
+
ones; with only_terminal=False, everything. Returns the count removed.
|
|
74
|
+
Never touches files on disk - this only edits the state records."""
|
|
75
|
+
if only_terminal:
|
|
76
|
+
targets = [h for h, r in self._records.items()
|
|
77
|
+
if r.get("status") in TERMINAL_STATUSES]
|
|
78
|
+
else:
|
|
79
|
+
targets = list(self._records)
|
|
80
|
+
for h in targets:
|
|
81
|
+
del self._records[h]
|
|
82
|
+
return len(targets)
|
|
83
|
+
|
|
84
|
+
@staticmethod
|
|
85
|
+
def encode_resume_data(resume_bytes):
|
|
86
|
+
return base64.b64encode(resume_bytes).decode("ascii")
|
|
87
|
+
|
|
88
|
+
@staticmethod
|
|
89
|
+
def decode_resume_data(encoded):
|
|
90
|
+
return base64.b64decode(encoded.encode("ascii"))
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: torrdown
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A single-session libtorrent download CLI with resume, dedup, and batch support.
|
|
5
|
+
Author: aryannxroot
|
|
6
|
+
License: MIT
|
|
7
|
+
License-File: LICENSE
|
|
8
|
+
Keywords: bittorrent,cli,downloader,libtorrent,torrent
|
|
9
|
+
Classifier: Development Status :: 4 - Beta
|
|
10
|
+
Classifier: Environment :: Console
|
|
11
|
+
Classifier: Intended Audience :: End Users/Desktop
|
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
13
|
+
Classifier: Operating System :: MacOS
|
|
14
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
21
|
+
Classifier: Topic :: Communications :: File Sharing
|
|
22
|
+
Classifier: Topic :: Internet
|
|
23
|
+
Requires-Python: <3.14,>=3.9
|
|
24
|
+
Requires-Dist: libtorrent<2.1,>=2.0
|
|
25
|
+
Requires-Dist: rich>=13
|
|
26
|
+
Provides-Extra: dev
|
|
27
|
+
Requires-Dist: pytest>=7; extra == 'dev'
|
|
28
|
+
Description-Content-Type: text/markdown
|
|
29
|
+
|
|
30
|
+
# TorrDown
|
|
31
|
+
|
|
32
|
+
A command-line torrent downloader built on a single shared `libtorrent` session.
|
|
33
|
+
It downloads several torrents at once, survives restarts, and installs as a real
|
|
34
|
+
`torrdown` command you can run from anywhere.
|
|
35
|
+
|
|
36
|
+
TorrDown started as a Google Colab notebook (still in the repo as
|
|
37
|
+
`legacy_download_gcollab.ipynb`) and grew into a proper, installable CLI.
|
|
38
|
+
|
|
39
|
+
## Features
|
|
40
|
+
|
|
41
|
+
- **Single shared libtorrent session** — all torrents run in one session (the way
|
|
42
|
+
libtorrent is meant to be used), not one session per torrent.
|
|
43
|
+
- **Concurrent downloads** with a fairness gate — only a bounded number compete
|
|
44
|
+
for metadata/peers at once; the rest queue, so a stuck magnet can't starve the
|
|
45
|
+
others or unfairly burn its own timeout.
|
|
46
|
+
- **Magnet links and `.torrent` files**, individually or in batches.
|
|
47
|
+
- **Automatic resume** — Ctrl+C, a crash, or a reboot doesn't lose progress;
|
|
48
|
+
downloads pick up where they left off on the next run (libtorrent resume data
|
|
49
|
+
is checkpointed to disk).
|
|
50
|
+
- **Info-hash dedup** — the same torrent won't be added twice.
|
|
51
|
+
- **Metadata-fetch timeout** — a magnet with no metadata-holding peers is skipped
|
|
52
|
+
after a while instead of hanging forever.
|
|
53
|
+
- **Batch mode** — download every magnet in a file; completed/failed entries are
|
|
54
|
+
commented out in place so re-runs don't repeat them.
|
|
55
|
+
- **Graceful shutdown** — Ctrl+C saves state cleanly before exiting.
|
|
56
|
+
- **Live progress bars** (via `rich`) for every active download.
|
|
57
|
+
- **Per-user config** with sensible defaults and clear override precedence.
|
|
58
|
+
|
|
59
|
+
## Requirements
|
|
60
|
+
|
|
61
|
+
- **macOS or Linux.** (Windows isn't supported yet.)
|
|
62
|
+
- **Python 3.9-3.13** somewhere on the machine. `libtorrent` publishes binary
|
|
63
|
+
wheels only for these versions, so TorrDown caps at `<3.14`. The installer
|
|
64
|
+
finds a compatible interpreter automatically; if you only have 3.14+, it tells
|
|
65
|
+
you how to get a supported one.
|
|
66
|
+
|
|
67
|
+
## Install
|
|
68
|
+
|
|
69
|
+
The easiest way, on a machine with Python 3.9-3.13:
|
|
70
|
+
|
|
71
|
+
```bash
|
|
72
|
+
pipx install torrdown # isolated, puts `torrdown` on your PATH
|
|
73
|
+
# or
|
|
74
|
+
pip install torrdown
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
That's it — `torrdown` is now a command you can run from anywhere.
|
|
78
|
+
|
|
79
|
+
### From source
|
|
80
|
+
|
|
81
|
+
The source repo is currently private. If you have access, you can clone it and
|
|
82
|
+
use the bundled installer (creates an isolated venv and symlinks the command):
|
|
83
|
+
|
|
84
|
+
```bash
|
|
85
|
+
git clone https://github.com/aryannxroot/TorrDown.git
|
|
86
|
+
cd TorrDown
|
|
87
|
+
./install.sh # then ./update.sh / ./uninstall.sh to manage it
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
For development:
|
|
91
|
+
|
|
92
|
+
```bash
|
|
93
|
+
python3.13 -m venv torr
|
|
94
|
+
source torr/bin/activate
|
|
95
|
+
pip install -e ".[dev]" # editable install + test deps
|
|
96
|
+
pytest # run the test suite
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
You can also run it without installing via `python -m torrdown`.
|
|
100
|
+
|
|
101
|
+
## Usage
|
|
102
|
+
|
|
103
|
+
```bash
|
|
104
|
+
torrdown # interactive menu (list files / paste magnet / batch)
|
|
105
|
+
torrdown add "magnet:?xt=..." # add a magnet (quote it - the & chars matter)
|
|
106
|
+
torrdown add movie.torrent # add a local .torrent file
|
|
107
|
+
torrdown add 1,3 # add local .torrent files by menu index
|
|
108
|
+
torrdown batch # download every magnet in the configured magnets.txt
|
|
109
|
+
torrdown batch links.txt # ...or from a specific file
|
|
110
|
+
torrdown resume # resume downloads interrupted by a crash/Ctrl+C
|
|
111
|
+
torrdown status # show tracked torrents with progress (ID, %, size)
|
|
112
|
+
torrdown remove <id> # drop a tracked entry by ID (from status) or label
|
|
113
|
+
torrdown clear # clear completed/failed entries (--all clears everything)
|
|
114
|
+
torrdown list # list local .torrent files
|
|
115
|
+
torrdown init # create the config/download/torrents directories
|
|
116
|
+
torrdown config # show resolved config and file locations
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
`status` shows how far each download got; `remove`/`clear` only edit the tracking
|
|
120
|
+
records — your downloaded files are never touched. For live speed on an active
|
|
121
|
+
download, use `torrdown resume` (it shows the running progress bars).
|
|
122
|
+
|
|
123
|
+
Downloads and interrupted work resume automatically on the next run.
|
|
124
|
+
|
|
125
|
+
## Configuration
|
|
126
|
+
|
|
127
|
+
Defaults (all overridable):
|
|
128
|
+
|
|
129
|
+
| Setting | Default |
|
|
130
|
+
|---|---|
|
|
131
|
+
| Download directory | `~/Downloads/torrdown` |
|
|
132
|
+
| Torrents directory (`.torrent` files for the menu / `list`) | `~/Downloads/torrdown/torrents` |
|
|
133
|
+
| Config / state directory | `~/.config/torrdown` (`%APPDATA%\torrdown` on Windows) |
|
|
134
|
+
| Listen port | 6881 |
|
|
135
|
+
| Max concurrent (metadata slots) | 3 |
|
|
136
|
+
| Metadata fetch timeout | 180s |
|
|
137
|
+
|
|
138
|
+
Drop `.torrent` files into the torrents directory to have them show up in the
|
|
139
|
+
interactive menu and `torrdown list`. Magnet links don't need any folder — just
|
|
140
|
+
`torrdown add "magnet:?..."`.
|
|
141
|
+
|
|
142
|
+
Precedence, highest first: **CLI flags** (`--download-dir`, `--config-dir`) >
|
|
143
|
+
**environment** (`TORRDOWN_DOWNLOAD_DIR`, `TORRDOWN_PORT`, ...) >
|
|
144
|
+
**`config.json`** (in the config dir) > **built-in defaults**.
|
|
145
|
+
|
|
146
|
+
Run `torrdown config` to see the resolved values and where the files live.
|
|
147
|
+
|
|
148
|
+
## How it works
|
|
149
|
+
|
|
150
|
+
- **`engine.py`** owns one `libtorrent` session and runs a single alert-driven
|
|
151
|
+
loop on the main thread: it adds torrents, watches libtorrent alerts
|
|
152
|
+
(metadata received, finished, error, resume-data saved), and polls each
|
|
153
|
+
torrent's status for the progress bars. Per-torrent status is read one handle
|
|
154
|
+
at a time on purpose — libtorrent's batched `get_torrent_status()` invokes a
|
|
155
|
+
Python callback from an internal worker thread and segfaults in this build.
|
|
156
|
+
- **`state.py`** persists a small `state.json` keyed by info-hash, including
|
|
157
|
+
base64-encoded libtorrent resume data, so interrupted torrents resume without
|
|
158
|
+
re-fetching metadata or re-hashing completed pieces. Writes are atomic and a
|
|
159
|
+
corrupt file is set aside rather than silently wiped.
|
|
160
|
+
- **`sources.py`** turns a user token (a magnet, a `.torrent` path, or a menu
|
|
161
|
+
index) into a concrete download source. This is the single seam where future
|
|
162
|
+
fetch adapters (e.g. searching a legitimate index) will plug in.
|
|
163
|
+
- **`config.py`** resolves paths and tunables across the precedence layers above.
|
|
164
|
+
- **`cli.py`** is the argparse front end (the subcommands above) plus the
|
|
165
|
+
interactive menu when run bare.
|
|
166
|
+
|
|
167
|
+
## Project layout
|
|
168
|
+
|
|
169
|
+
```
|
|
170
|
+
torrdown/ the package
|
|
171
|
+
cli.py argparse subcommands + interactive menu
|
|
172
|
+
config.py path + settings resolution
|
|
173
|
+
sources.py token -> download source (future fetch seam)
|
|
174
|
+
engine.py the shared-session libtorrent engine
|
|
175
|
+
state.py resume-state persistence
|
|
176
|
+
magnets.py batch magnets file read + archive
|
|
177
|
+
tests/ pure-logic pytest suite
|
|
178
|
+
install.sh installer (macOS + Linux)
|
|
179
|
+
uninstall.sh uninstaller
|
|
180
|
+
update.sh updater
|
|
181
|
+
pyproject.toml packaging + dependencies
|
|
182
|
+
PLAN.md what we're building
|
|
183
|
+
PROGRESS.md living status tracker
|
|
184
|
+
ROADMAP.md deferred work (auto-fetch, PyPI, perf notes)
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
## Notes & limitations
|
|
188
|
+
|
|
189
|
+
- **Speed** is bounded by your internet connection and the swarm, not by
|
|
190
|
+
TorrDown — see [ROADMAP.md](ROADMAP.md) for the full performance discussion.
|
|
191
|
+
- **Automated fetching** of torrents from external sources isn't built yet;
|
|
192
|
+
when it is, it will target only legitimate sources (Internet Archive, Linux
|
|
193
|
+
distros, Creative Commons). See [ROADMAP.md](ROADMAP.md).
|
|
194
|
+
- **Windows** is not supported yet.
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
torrdown/__init__.py,sha256=TqPymXrxElpB0BizEGFnqXg3dZOniu4thfa-0n1z6bs,82
|
|
2
|
+
torrdown/__main__.py,sha256=d7Zm05MGZ8Rj9t5Vqo2kj4bzD4Dh9ZkZ2ClFjit90yQ,97
|
|
3
|
+
torrdown/cli.py,sha256=7u0tMpdofilMGa02nIKB3XH_uxtBR6KDduehM6QZcqE,10911
|
|
4
|
+
torrdown/config.py,sha256=6gpbtVn3IaCYyNwWgQ23P2sgyCCzEVgLfUKjxxwC3Ow,5497
|
|
5
|
+
torrdown/engine.py,sha256=xkX0BEA7ZE30g-fOIepUzDaHn2X02x4MuJQ6fdevLis,14704
|
|
6
|
+
torrdown/magnets.py,sha256=ZVTBbKapc7Dyq8k9Va3gZC68owFLfApv6HRzX2w2ekU,1485
|
|
7
|
+
torrdown/sources.py,sha256=SUijk42KISW4W7lot7fvMdllzKpuEIsljTEh_DyTzhE,2782
|
|
8
|
+
torrdown/state.py,sha256=qWbun1_Km5l5myv-8IV-Qzt4lKeibCIMdL-SKWQzAZQ,3193
|
|
9
|
+
torrdown-0.1.0.dist-info/METADATA,sha256=8rRREGznzw2Jjx9gCOO2tCmPbRnjfQqMKN2B7nnXPpY,8354
|
|
10
|
+
torrdown-0.1.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
|
|
11
|
+
torrdown-0.1.0.dist-info/entry_points.txt,sha256=IMAZe368bsWXfmExb5vgho53LL5ad5VUHJ3EksDXx7Q,47
|
|
12
|
+
torrdown-0.1.0.dist-info/licenses/LICENSE,sha256=iJ_F87E3mFyw2PomWSmIjWhAympMG-7NLpf5DEoKhfI,1068
|
|
13
|
+
torrdown-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 aryannxroot
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|