weed-cli 0.1.0__tar.gz → 1.1.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
weed_cli-1.1.2/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Ryan Scarbery
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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: weed-cli
3
- Version: 0.1.0
3
+ Version: 1.1.2
4
4
  Summary: Censorship-resistant video PoC — discovery/hosting/download over signed relay events, a real Kademlia DHT, or a TLS-capable NAT-traversal tunnel
5
5
  License: MIT
6
6
  Keywords: p2p,video,censorship-resistant,discovery,dht,kademlia,nat-traversal
@@ -13,6 +13,7 @@ Classifier: Topic :: Communications :: File Sharing
13
13
  Classifier: Topic :: Internet
14
14
  Requires-Python: >=3.11
15
15
  Description-Content-Type: text/markdown
16
+ License-File: LICENSE
16
17
  Requires-Dist: btcvm>=1.3.7
17
18
  Requires-Dist: cryptography>=41.0
18
19
  Provides-Extra: dev
@@ -25,6 +26,7 @@ Provides-Extra: qr
25
26
  Requires-Dist: qrcode>=7.4; extra == "qr"
26
27
  Provides-Extra: viz
27
28
  Requires-Dist: matplotlib>=3.8; extra == "viz"
29
+ Dynamic: license-file
28
30
 
29
31
  # Censorship-resistant video platform — PoC notes
30
32
 
@@ -39,6 +39,26 @@ IDENTITY_PATH = os.path.expanduser('~/.weed_identity.key')
39
39
  IDENTITY_ARMOR_HEADER = '-----BEGIN WEED IDENTITY KEY-----'
40
40
  IDENTITY_ARMOR_FOOTER = '-----END WEED IDENTITY KEY-----'
41
41
 
42
+ TAGLINE = 'we do in 4 what others do in 5'
43
+
44
+
45
+ def weed_version():
46
+ """Installed package version (pyproject.toml's source of truth), with a
47
+ fallback for running straight from source without `pip install -e .`."""
48
+ try:
49
+ from importlib.metadata import version, PackageNotFoundError
50
+ try:
51
+ return version('weed-cli')
52
+ except PackageNotFoundError:
53
+ pass
54
+ except ImportError:
55
+ pass
56
+ return '0.0.0-dev'
57
+
58
+
59
+ def weed_banner():
60
+ return f'weed v{weed_version()} — {TAGLINE}'
61
+
42
62
 
43
63
  def _armor_identity(raw_bytes):
44
64
  """Base64 text with a header/footer — NOT real OpenPGP armor (no
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "weed-cli"
7
- version = "0.1.0"
7
+ version = "1.1.2"
8
8
  description = "Censorship-resistant video PoC — discovery/hosting/download over signed relay events, a real Kademlia DHT, or a TLS-capable NAT-traversal tunnel"
9
9
  readme = "README.md"
10
10
  license = { text = "MIT" }
@@ -42,7 +42,7 @@ def _bg(fn, *args, **kwargs):
42
42
 
43
43
  class WeedShell(cmd.Cmd):
44
44
  intro = (
45
- '\n weed — censorship-resistant video PoC node\n'
45
+ f'\n {node.weed_banner()}\n'
46
46
  ' Type help or ? for commands. Tab completes. Ctrl-D or q to exit.\n'
47
47
  )
48
48
  prompt = 'weed> '
@@ -106,11 +106,12 @@ class WeedShell(cmd.Cmd):
106
106
  on --relay (default: your session's default relay — see `relay`)
107
107
  unless --no-announce is given. --price sets what download charges
108
108
  (default free). --tunnel registers with a tunnel_relay.py instead of
109
- relying on a reachable inbound port — for hosting behind NAT/CGNAT,
110
- and only supports one file at a time, so requires --file if
111
- archive_dir has more than one. Prefix tls:// if the relay
112
- terminates TLS at the edge (e.g. a Fly service with handlers =
113
- ["tls"]) — the relay process itself never needs to know."""
109
+ relying on a reachable inbound port — for hosting behind NAT/CGNAT.
110
+ Registers one control connection per file (REGISTER's token is
111
+ each file's own content hash), so a whole tree tunnels fine, not
112
+ just a single file. Prefix tls:// if the relay terminates TLS at
113
+ the edge (e.g. a Fly service with handlers = ["tls"]) — the relay
114
+ process itself never needs to know."""
114
115
  parts = shlex.split(arg)
115
116
  if not parts:
116
117
  print(' usage: host <archive_dir> [--file NAME] [--port N] [--price SAT] '
@@ -148,10 +149,6 @@ class WeedShell(cmd.Cmd):
148
149
  i += 1
149
150
 
150
151
  entries = node.load_manifest_entries(archive_dir, file_name)
151
- if tunnel and len(entries) > 1:
152
- print(f' {len(entries)} files in {archive_dir} but --tunnel only serves one at a time '
153
- f'— pass --file NAME to pick one')
154
- return
155
152
  # fail fast, before announcing anything — a manifest entry with no
156
153
  # matching chunk data would otherwise get announced to the relay
157
154
  # and only fail later, in the background server thread (onecmd's
@@ -165,17 +162,19 @@ class WeedShell(cmd.Cmd):
165
162
  elif not relay:
166
163
  print(' no relay set (run `relay` first, or pass --relay) — hosting without announcing')
167
164
  if tunnel:
168
- entry = entries[0]
165
+ # one control connection per file, each registered under its
166
+ # own content hash — see do_host's docstring
169
167
  relay_host, relay_port, use_tls = node._parse_tunnel(tunnel)
170
168
  expanded_dir = os.path.expanduser(archive_dir)
171
- file_path = entry.get('last_path') or os.path.join(expanded_dir, entry['name'])
172
- tt = threading.Thread(target=_bg,
173
- args=(node.run_host_tunnel, relay_host, relay_port,
174
- entry['sha256'], entry, all_leaves[entry['sha256']],
175
- file_path, price),
176
- kwargs={'use_tls': use_tls, 'quiet': True}, daemon=True)
177
- tt.start()
178
- self._host_threads.append(tt)
169
+ for entry in entries:
170
+ file_path = entry.get('last_path') or os.path.join(expanded_dir, entry['name'])
171
+ tt = threading.Thread(target=_bg,
172
+ args=(node.run_host_tunnel, relay_host, relay_port,
173
+ entry['sha256'], entry, all_leaves[entry['sha256']],
174
+ file_path, price),
175
+ kwargs={'use_tls': use_tls, 'quiet': True}, daemon=True)
176
+ tt.start()
177
+ self._host_threads.append(tt)
179
178
  t = threading.Thread(target=_bg,
180
179
  args=(node.run_host_server, archive_dir, file_name, port),
181
180
  kwargs={'quiet': True, 'price': price}, daemon=True)
@@ -20,6 +20,7 @@ import mimetypes
20
20
  import os
21
21
  import sys
22
22
  import threading
23
+ import time
23
24
  import uuid
24
25
  from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
25
26
  from urllib.parse import urlparse, parse_qs
@@ -35,6 +36,7 @@ except ImportError:
35
36
 
36
37
  WEB_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'web')
37
38
  DEFAULT_RELAY = 'http://127.0.0.1:9101'
39
+ LIBRARY_PATH = os.path.expanduser('~/.weed_library.json')
38
40
 
39
41
 
40
42
  class _JobStdout:
@@ -82,6 +84,52 @@ def _quiet():
82
84
  _hosts = {} # host_id -> dict describing an actively-hosted file
83
85
  _jobs = {} # job_id -> dict describing a download's progress/result
84
86
  _lock = threading.Lock()
87
+
88
+ # what's been downloaded/liked/subscribed, persisted to disk so a page
89
+ # reload -- or a server restart -- doesn't forget any of it. Downloads
90
+ # are keyed by content_hash (one record per piece of content, most
91
+ # recent job wins); likes/subscriptions are just lists of hashes/pubkeys.
92
+ # All access goes through _lock, same as _hosts/_jobs.
93
+ _library = {'downloads': {}, 'likes': [], 'subscriptions': []}
94
+
95
+
96
+ def _load_library():
97
+ global _library
98
+ try:
99
+ with open(LIBRARY_PATH) as f:
100
+ data = json.load(f)
101
+ _library = {
102
+ 'downloads': data.get('downloads') or {},
103
+ 'likes': data.get('likes') or [],
104
+ 'subscriptions': data.get('subscriptions') or [],
105
+ }
106
+ except (FileNotFoundError, json.JSONDecodeError):
107
+ pass
108
+
109
+
110
+ def _save_library():
111
+ """Caller must hold _lock. Written to a tmp file + os.replace so a
112
+ crash mid-write can't leave a half-written, unparseable JSON file
113
+ behind -- this runs on every like/subscribe/download-finish, not just
114
+ at shutdown."""
115
+ tmp = LIBRARY_PATH + '.tmp'
116
+ with open(tmp, 'w') as f:
117
+ json.dump(_library, f)
118
+ os.replace(tmp, LIBRARY_PATH)
119
+
120
+
121
+ def _rehydrate_jobs_from_library():
122
+ """Lets a restarted server's Downloads tab and Discover "already
123
+ downloaded" check keep working against the same job_ids as before --
124
+ _jobs is otherwise purely in-memory and would forget every finished
125
+ download on restart even though the files (and _library) are still
126
+ there."""
127
+ for content_hash, rec in _library['downloads'].items():
128
+ _jobs[rec['job_id']] = {
129
+ 'status': 'done', 'idx': 0, 'n_chunks': None,
130
+ 'content_hash': content_hash, 'path': rec['path'],
131
+ 'title': rec.get('title'), 'error': None,
132
+ }
85
133
  _lan_url = None # set once in run_web_ui() -- the base URL a phone on the
86
134
  # same LAN can actually reach this server at, or None if
87
135
  # it can't (bound to 127.0.0.1). The client can't compute
@@ -154,7 +202,7 @@ def _run_host_job(host_id, archive_dir, file_name, port, price, relay_urls, adve
154
202
  _hosts[host_id].update(status='error', error=f'{type(e).__name__}: {e}')
155
203
 
156
204
 
157
- def _run_download_job(job_id, content_hash, relay_urls, out_path, k, use_lightning):
205
+ def _run_download_job(job_id, content_hash, relay_urls, out_path, k, use_lightning, title=None):
158
206
  def on_progress(idx, n_chunks):
159
207
  with _lock:
160
208
  _jobs[job_id].update(idx=idx, n_chunks=n_chunks)
@@ -165,6 +213,11 @@ def _run_download_job(job_id, content_hash, relay_urls, out_path, k, use_lightni
165
213
  use_lightning=use_lightning, on_progress=on_progress)
166
214
  with _lock:
167
215
  _jobs[job_id].update(status='done', path=path)
216
+ _library['downloads'][content_hash] = {
217
+ 'content_hash': content_hash, 'job_id': job_id, 'path': path,
218
+ 'title': title, 'downloaded_at': time.time(),
219
+ }
220
+ _save_library()
168
221
  except SystemExit as e:
169
222
  with _lock:
170
223
  _jobs[job_id].update(status='error', error=str(e))
@@ -202,6 +255,13 @@ class Handler(BaseHTTPRequestHandler):
202
255
  if path == '/api/hosts':
203
256
  with _lock:
204
257
  return self._json({'hosts': list(_hosts.values())})
258
+ if path == '/api/library':
259
+ with _lock:
260
+ return self._json({
261
+ 'downloads': list(_library['downloads'].values()),
262
+ 'likes': list(_library['likes']),
263
+ 'subscriptions': list(_library['subscriptions']),
264
+ })
205
265
  if path.startswith('/api/download/'):
206
266
  with _lock:
207
267
  job = _jobs.get(path[len('/api/download/'):])
@@ -268,13 +328,14 @@ class Handler(BaseHTTPRequestHandler):
268
328
  out_path = body.get('out_path') or f'download_{content_hash[:16]}'
269
329
  k = int(body.get('k') or 3)
270
330
  use_lightning = bool(body.get('lightning'))
331
+ title = body.get('title')
271
332
 
272
333
  job_id = uuid.uuid4().hex[:12]
273
334
  with _lock:
274
335
  _jobs[job_id] = {'status': 'running', 'idx': 0, 'n_chunks': None,
275
- 'content_hash': content_hash, 'path': None, 'error': None}
336
+ 'content_hash': content_hash, 'path': None, 'title': title, 'error': None}
276
337
  threading.Thread(target=_run_download_job,
277
- args=(job_id, content_hash, relay_urls, out_path, k, use_lightning),
338
+ args=(job_id, content_hash, relay_urls, out_path, k, use_lightning, title),
278
339
  daemon=True).start()
279
340
  self._json({'job_id': job_id})
280
341
 
@@ -284,7 +345,12 @@ class Handler(BaseHTTPRequestHandler):
284
345
  return self._json({'error': 'content_hash required'}, status=400)
285
346
  identity = _identity()
286
347
  event = identity.sign_event('like', content_hash=content_hash)
287
- self._json({'result': node.post_event(body.get('relay') or DEFAULT_RELAY, event)})
348
+ result = node.post_event(body.get('relay') or DEFAULT_RELAY, event)
349
+ with _lock:
350
+ if content_hash not in _library['likes']:
351
+ _library['likes'].append(content_hash)
352
+ _save_library()
353
+ self._json({'result': result})
288
354
 
289
355
  def _handle_subscribe(self, body):
290
356
  target_pubkey = body.get('target_pubkey')
@@ -292,7 +358,12 @@ class Handler(BaseHTTPRequestHandler):
292
358
  return self._json({'error': 'target_pubkey required'}, status=400)
293
359
  identity = _identity()
294
360
  event = identity.sign_event('subscribe', target_pubkey=target_pubkey)
295
- self._json({'result': node.post_event(body.get('relay') or DEFAULT_RELAY, event)})
361
+ result = node.post_event(body.get('relay') or DEFAULT_RELAY, event)
362
+ with _lock:
363
+ if target_pubkey not in _library['subscriptions']:
364
+ _library['subscriptions'].append(target_pubkey)
365
+ _save_library()
366
+ self._json({'result': result})
296
367
 
297
368
  def _handle_stream(self, job_id):
298
369
  """Serve an already-downloaded job's file with real HTTP range
@@ -398,6 +469,8 @@ class Handler(BaseHTTPRequestHandler):
398
469
 
399
470
  def run_web_ui(port=8080, bind_host='127.0.0.1', quiet=False):
400
471
  global _lan_url
472
+ _load_library()
473
+ _rehydrate_jobs_from_library()
401
474
  srv = ThreadingHTTPServer((bind_host, port), Handler)
402
475
 
403
476
  reachable_host = _detect_lan_ip() if bind_host == '0.0.0.0' else bind_host
@@ -49,9 +49,11 @@ def run_make(target):
49
49
 
50
50
 
51
51
  def build_parser():
52
+ import node
52
53
  parser = argparse.ArgumentParser(
53
54
  prog='weed',
54
- description='Censorship-resistant video PoC — real mechanisms behind the #all-pdx brainstorm.')
55
+ description=f'{node.weed_banner()} — real mechanisms behind the #all-pdx brainstorm.')
56
+ parser.add_argument('--version', action='version', version=node.weed_banner())
55
57
  sub = parser.add_subparsers(dest='command', required=False)
56
58
 
57
59
  sub.add_parser('shell', help='interactive shell with tab completion (also the default with no command)')
@@ -139,9 +141,6 @@ def cmd_host(args):
139
141
  import node
140
142
  identity = node.load_or_create_identity()
141
143
  entries = node.load_manifest_entries(args.archive_dir, args.file)
142
- if args.tunnel and len(entries) > 1:
143
- sys.exit(f"{len(entries)} files in {args.archive_dir} but --tunnel only serves one at a "
144
- f"time — pass --file NAME to pick one")
145
144
  # fail fast, before announcing anything — a manifest entry with no
146
145
  # matching chunk data would otherwise get announced to the relay and
147
146
  # only fail later, in the background server thread
@@ -153,14 +152,19 @@ def cmd_host(args):
153
152
  tunnel=args.tunnel)
154
153
  print(f"announced {entry['name']} on {relay_url}: {result}")
155
154
  if args.tunnel:
156
- entry = entries[0]
155
+ # REGISTER's token is the file's own content hash (see
156
+ # run_host_tunnel/connect_via_tunnel), so a whole tree just means
157
+ # one control connection per file, each registered under its own
158
+ # hash — no protocol change needed, CONNECT already looks a
159
+ # downloader's requested hash up the same way.
157
160
  relay_host, relay_port, use_tls = node._parse_tunnel(args.tunnel)
158
161
  archive_dir = os.path.expanduser(args.archive_dir)
159
- file_path = entry.get('last_path') or os.path.join(archive_dir, entry['name'])
160
- threading.Thread(target=node.run_host_tunnel,
161
- args=(relay_host, relay_port, entry['sha256'], entry,
162
- all_leaves[entry['sha256']], file_path, args.price),
163
- kwargs={'use_tls': use_tls}, daemon=True).start()
162
+ for entry in entries:
163
+ file_path = entry.get('last_path') or os.path.join(archive_dir, entry['name'])
164
+ threading.Thread(target=node.run_host_tunnel,
165
+ args=(relay_host, relay_port, entry['sha256'], entry,
166
+ all_leaves[entry['sha256']], file_path, args.price),
167
+ kwargs={'use_tls': use_tls}, daemon=True).start()
164
168
  node.run_host_server(args.archive_dir, args.file, args.port, price=args.price)
165
169
 
166
170
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: weed-cli
3
- Version: 0.1.0
3
+ Version: 1.1.2
4
4
  Summary: Censorship-resistant video PoC — discovery/hosting/download over signed relay events, a real Kademlia DHT, or a TLS-capable NAT-traversal tunnel
5
5
  License: MIT
6
6
  Keywords: p2p,video,censorship-resistant,discovery,dht,kademlia,nat-traversal
@@ -13,6 +13,7 @@ Classifier: Topic :: Communications :: File Sharing
13
13
  Classifier: Topic :: Internet
14
14
  Requires-Python: >=3.11
15
15
  Description-Content-Type: text/markdown
16
+ License-File: LICENSE
16
17
  Requires-Dist: btcvm>=1.3.7
17
18
  Requires-Dist: cryptography>=41.0
18
19
  Provides-Extra: dev
@@ -25,6 +26,7 @@ Provides-Extra: qr
25
26
  Requires-Dist: qrcode>=7.4; extra == "qr"
26
27
  Provides-Extra: viz
27
28
  Requires-Dist: matplotlib>=3.8; extra == "viz"
29
+ Dynamic: license-file
28
30
 
29
31
  # Censorship-resistant video platform — PoC notes
30
32
 
@@ -1,3 +1,4 @@
1
+ LICENSE
1
2
  README.md
2
3
  dht.py
3
4
  discovery_relay.py
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes