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 ADDED
@@ -0,0 +1,3 @@
1
+ """TorrDown - a single-session libtorrent download CLI."""
2
+
3
+ __version__ = "0.1.0"
torrdown/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ """Enables `python -m torrdown`."""
2
+ from .cli import main
3
+
4
+ if __name__ == "__main__":
5
+ main()
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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.31.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ torrdown = torrdown.cli:main
@@ -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.