weed-cli 1.6.2__tar.gz → 1.6.8__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: weed-cli
3
- Version: 1.6.2
3
+ Version: 1.6.8
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
@@ -174,19 +174,28 @@ blind.
174
174
  ### Docker
175
175
 
176
176
  `Dockerfile.node` + `docker-compose.node.yml` package `web_ui.py` to run
177
- somewhere other than a laptop.
177
+ somewhere other than a laptop — alongside its own local discovery relay
178
+ (`Dockerfile.discovery-relay`), so there's always something real for it
179
+ to announce to and discover from out of the box.
178
180
 
179
181
  ```bash
180
182
  make node # build + run, http://127.0.0.1:8080
181
183
  make node-down # stop it — data persists
184
+ make node-shell # interactive weed shell, same container as the running node
182
185
  WEED_SHARE_DIR=~/Movies make node # mount a real directory of .ott archives at /share
183
- docker compose -f docker-compose.node.yml exec node python3 shell.py # interactive shell, same container
184
186
  docker compose -f docker-compose.node.yml exec node python3 weed.py discover # or a one-off CLI command
185
187
  ```
186
188
 
187
189
  - Identity key, reputation store, and library manifest all persist in a
188
190
  named volume (`node-data`, mounted at `$HOME=/data`) — one node keeps
189
191
  the same pubkey (and everything vouching for it) across restarts.
192
+ - The `relay` service is `node`'s default `WEED_RELAY` (`http://relay:9101`,
193
+ resolved over Compose's own network) — its events persist in their own
194
+ `relay-data` volume, and port 9101 is published to the host too, for
195
+ curling `/events` directly or pointing a non-Docker `weed`/shell session
196
+ at the exact same relay. Override `WEED_RELAY` (shell env or `.env`,
197
+ see `.env.sample`) to point at public infra instead, e.g. for a real
198
+ end-to-end test.
190
199
  - `/share` is a separate *bind* mount, not a named volume, so the actual
191
200
  `.ott` archives you're hosting are real files on the host you can see
192
201
  and manage directly. Point the web UI's Host form at `/share`.
@@ -144,19 +144,28 @@ blind.
144
144
  ### Docker
145
145
 
146
146
  `Dockerfile.node` + `docker-compose.node.yml` package `web_ui.py` to run
147
- somewhere other than a laptop.
147
+ somewhere other than a laptop — alongside its own local discovery relay
148
+ (`Dockerfile.discovery-relay`), so there's always something real for it
149
+ to announce to and discover from out of the box.
148
150
 
149
151
  ```bash
150
152
  make node # build + run, http://127.0.0.1:8080
151
153
  make node-down # stop it — data persists
154
+ make node-shell # interactive weed shell, same container as the running node
152
155
  WEED_SHARE_DIR=~/Movies make node # mount a real directory of .ott archives at /share
153
- docker compose -f docker-compose.node.yml exec node python3 shell.py # interactive shell, same container
154
156
  docker compose -f docker-compose.node.yml exec node python3 weed.py discover # or a one-off CLI command
155
157
  ```
156
158
 
157
159
  - Identity key, reputation store, and library manifest all persist in a
158
160
  named volume (`node-data`, mounted at `$HOME=/data`) — one node keeps
159
161
  the same pubkey (and everything vouching for it) across restarts.
162
+ - The `relay` service is `node`'s default `WEED_RELAY` (`http://relay:9101`,
163
+ resolved over Compose's own network) — its events persist in their own
164
+ `relay-data` volume, and port 9101 is published to the host too, for
165
+ curling `/events` directly or pointing a non-Docker `weed`/shell session
166
+ at the exact same relay. Override `WEED_RELAY` (shell env or `.env`,
167
+ see `.env.sample`) to point at public infra instead, e.g. for a real
168
+ end-to-end test.
160
169
  - `/share` is a separate *bind* mount, not a named volume, so the actual
161
170
  `.ott` archives you're hosting are real files on the host you can see
162
171
  and manage directly. Point the web UI's Host form at `/share`.
@@ -1034,6 +1034,26 @@ def _relay_url_hint(relay_url):
1034
1034
  return None
1035
1035
 
1036
1036
 
1037
+ def _normalize_relay_url(relay_url):
1038
+ """A bare host:port with no scheme (e.g. '127.0.0.1:9101', the most
1039
+ natural way to type a local relay) isn't a URL urllib can parse at
1040
+ all -- everything before the first ':' becomes the *scheme*, so this
1041
+ reads as scheme '127.0.0.1' and raises "unknown url type", not a
1042
+ connection error. That already lands on post_event/fetch_events'
1043
+ existing except clauses (an unreachable relay is routine, not
1044
+ exceptional), which is exactly the trap: hosting against a
1045
+ scheme-less relay used to report status: running with zero actual
1046
+ announcements ever reaching it, and discover against one silently
1047
+ came back empty -- nothing hinted that the URL itself, not the
1048
+ relay, was the problem. Every relay this project actually ships or
1049
+ documents is plain HTTP(S), so defaulting a missing scheme to
1050
+ http:// turns the common typo into the thing the user obviously
1051
+ meant instead of a silent no-op."""
1052
+ if '://' not in relay_url:
1053
+ return 'http://' + relay_url
1054
+ return relay_url
1055
+
1056
+
1037
1057
  _dns_bound_pool = concurrent.futures.ThreadPoolExecutor(max_workers=8, thread_name_prefix='urlopen')
1038
1058
 
1039
1059
 
@@ -1059,6 +1079,7 @@ def post_event(relay_url, event):
1059
1079
  hint = _relay_url_hint(relay_url)
1060
1080
  if hint:
1061
1081
  return {'ok': False, 'error': hint}
1082
+ relay_url = _normalize_relay_url(relay_url)
1062
1083
  req = urllib.request.Request(
1063
1084
  f'{relay_url}/event', data=json.dumps(event).encode(),
1064
1085
  headers={'Content-Type': 'application/json'}, method='POST')
@@ -1082,6 +1103,7 @@ def fetch_events(relay_url, event_type=None):
1082
1103
  if hint:
1083
1104
  print(f" {relay_url}: {hint}")
1084
1105
  return None
1106
+ relay_url = _normalize_relay_url(relay_url)
1085
1107
  url = f'{relay_url}/events' + (f'?type={event_type}' if event_type else '')
1086
1108
  try:
1087
1109
  with _urlopen_bounded(url, timeout=5) as resp:
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "weed-cli"
7
- version = "1.6.2"
7
+ version = "1.6.8"
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" }
@@ -179,6 +179,23 @@ class WeedShell(cmd.Cmd):
179
179
  # and only fail later, in the background server thread (onecmd's
180
180
  # exception net doesn't reach into background threads)
181
181
  all_leaves = {e['sha256']: node.load_leaves(archive_dir, e['sha256']) for e in entries}
182
+ # Same reasoning as web_ui.py's _run_host_job: bind the real,
183
+ # permanent listening socket now, before announcing anything to a
184
+ # relay, and hand this exact socket to run_host_server below
185
+ # instead of it binding a second one later. Without this, a port
186
+ # already in use (another `host` still running from earlier in
187
+ # this same session, or a persisted web-UI host on the same
188
+ # default port) only fails once the background thread started
189
+ # below gets around to its own bind — by then `announced ... on
190
+ # relay: {'ok': True, ...}` has already printed, and the relay
191
+ # has a listing for a host that was never actually reachable, with
192
+ # nothing pointing back at why until a downloader's own possession
193
+ # challenge mysteriously fails against it.
194
+ try:
195
+ bound_sock = node.bind_host_port(port)
196
+ except OSError as e:
197
+ print(f' ✗ cannot bind port {port}: {e} — not announcing, nothing started')
198
+ return
182
199
  if relay and not no_announce:
183
200
  ott_status = node.ott_commit_status(archive_dir)
184
201
  for entry in entries:
@@ -204,7 +221,8 @@ class WeedShell(cmd.Cmd):
204
221
  self._host_threads.append(tt)
205
222
  t = threading.Thread(target=_bg,
206
223
  args=(node.run_host_server, archive_dir, file_name, port),
207
- kwargs={'quiet': True, 'price': price, 'ln_node': ln_node}, daemon=True)
224
+ kwargs={'quiet': True, 'price': price, 'ln_node': ln_node, 'sock': bound_sock},
225
+ daemon=True)
208
226
  t.start()
209
227
  self._host_threads.append(t)
210
228
  price_note = f', {price} sat/download' if price else ', free'
@@ -125,8 +125,14 @@ _lock = threading.Lock()
125
125
  # reload -- or a server restart -- doesn't forget any of it. Downloads
126
126
  # are keyed by content_hash (one record per piece of content, most
127
127
  # recent job wins); likes/subscriptions are just lists of hashes/pubkeys.
128
- # All access goes through _lock, same as _hosts/_jobs.
129
- _library = {'downloads': {}, 'likes': [], 'subscriptions': []}
128
+ # playlists are purely local organization -- unlike likes/subscriptions
129
+ # they're never signed or gossiped to a relay, nobody else has any
130
+ # business seeing how you've grouped your own downloads -- a list of
131
+ # {id, name, items: [{content_hash, title, signer_pubkey}, ...]}, id
132
+ # stable across renames so the UI can keep pointing at the same playlist
133
+ # while its name changes underneath it. All access goes through _lock,
134
+ # same as _hosts/_jobs.
135
+ _library = {'downloads': {}, 'likes': [], 'subscriptions': [], 'playlists': []}
130
136
 
131
137
  # what to (re-)host on startup -- _hosts above is pure in-memory runtime
132
138
  # state, so a restart (a fresh `make node`, a Docker container recreated
@@ -245,6 +251,7 @@ def _load_library():
245
251
  'downloads': data.get('downloads') or {},
246
252
  'likes': data.get('likes') or [],
247
253
  'subscriptions': data.get('subscriptions') or [],
254
+ 'playlists': data.get('playlists') or [],
248
255
  }
249
256
  except (FileNotFoundError, json.JSONDecodeError):
250
257
  pass
@@ -351,8 +358,21 @@ def _run_host_job(host_id, archive_dir, file_name, port, price, relay_urls, adve
351
358
  for entry in entries:
352
359
  for relay_url in relay_urls:
353
360
  host_addr = f'{advertise_host}:{port}'
354
- node.publish(identity, relay_url, entry['sha256'], entry['name'], host_addr,
355
- tunnel=tunnel, ott_status=ott_status)
361
+ result = node.publish(identity, relay_url, entry['sha256'], entry['name'], host_addr,
362
+ tunnel=tunnel, ott_status=ott_status)
363
+ # publish()/post_event() report a failed announce as a
364
+ # normal {'ok': False, ...} return, not an exception (an
365
+ # unreachable or malformed relay is routine, not
366
+ # exceptional -- see post_event's own docstring) -- which
367
+ # this loop used to just ignore entirely, so a host could
368
+ # sit at status: running, "announced" on a relay it never
369
+ # actually reached, with nothing in the UI to say so.
370
+ # do_host (shell.py) already prints every result
371
+ # unconditionally; this only prints the failures; a
372
+ # working host's log staying free of routine "ok: True"
373
+ # noise matters more here than in a CLI's own scrollback.
374
+ if isinstance(result, dict) and not result.get('ok', True):
375
+ print(f' ✗ announce to {relay_url} failed: {result.get("error")}')
356
376
  announced = relay_urls
357
377
  files = [{'name': e['name'], 'content_hash': e['sha256']} for e in entries]
358
378
  with _lock:
@@ -479,6 +499,7 @@ class Handler(BaseHTTPRequestHandler):
479
499
  'downloads': list(_library['downloads'].values()),
480
500
  'likes': list(_library['likes']),
481
501
  'subscriptions': list(_library['subscriptions']),
502
+ 'playlists': list(_library['playlists']),
482
503
  })
483
504
  if path.startswith('/api/download/'):
484
505
  job_id = path[len('/api/download/'):]
@@ -539,6 +560,12 @@ class Handler(BaseHTTPRequestHandler):
539
560
  '/api/host': self._handle_host, '/api/download': self._handle_download,
540
561
  '/api/like': self._handle_like, '/api/subscribe': self._handle_subscribe,
541
562
  '/api/verify': self._handle_verify,
563
+ '/api/playlists/create': self._handle_playlist_create,
564
+ '/api/playlists/rename': self._handle_playlist_rename,
565
+ '/api/playlists/delete': self._handle_playlist_delete,
566
+ '/api/playlists/add': self._handle_playlist_add,
567
+ '/api/playlists/remove': self._handle_playlist_remove,
568
+ '/api/playlists/move': self._handle_playlist_move,
542
569
  }
543
570
  handler = handlers.get(path)
544
571
  if not handler:
@@ -617,6 +644,94 @@ class Handler(BaseHTTPRequestHandler):
617
644
  _save_library()
618
645
  self._json({'result': result})
619
646
 
647
+ def _handle_playlist_create(self, body):
648
+ name = (body.get('name') or '').strip()
649
+ if not name:
650
+ return self._json({'error': 'name required'}, status=400)
651
+ playlist = {'id': uuid.uuid4().hex[:12], 'name': name, 'items': []}
652
+ with _lock:
653
+ _library['playlists'].append(playlist)
654
+ _save_library()
655
+ self._json({'playlist': playlist})
656
+
657
+ def _handle_playlist_rename(self, body):
658
+ playlist_id = body.get('playlist_id')
659
+ name = (body.get('name') or '').strip()
660
+ if not playlist_id or not name:
661
+ return self._json({'error': 'playlist_id and name required'}, status=400)
662
+ with _lock:
663
+ playlist = next((p for p in _library['playlists'] if p['id'] == playlist_id), None)
664
+ if not playlist:
665
+ return self._json({'error': 'no such playlist'}, status=404)
666
+ playlist['name'] = name
667
+ _save_library()
668
+ self._json({'playlist': playlist})
669
+
670
+ def _handle_playlist_delete(self, body):
671
+ playlist_id = body.get('playlist_id')
672
+ if not playlist_id:
673
+ return self._json({'error': 'playlist_id required'}, status=400)
674
+ with _lock:
675
+ _library['playlists'] = [p for p in _library['playlists'] if p['id'] != playlist_id]
676
+ _save_library()
677
+ self._json({'ok': True})
678
+
679
+ def _handle_playlist_add(self, body):
680
+ playlist_id = body.get('playlist_id')
681
+ content_hash = body.get('content_hash')
682
+ if not playlist_id or not content_hash:
683
+ return self._json({'error': 'playlist_id and content_hash required'}, status=400)
684
+ with _lock:
685
+ playlist = next((p for p in _library['playlists'] if p['id'] == playlist_id), None)
686
+ if not playlist:
687
+ return self._json({'error': 'no such playlist'}, status=404)
688
+ # re-adding something already in the playlist just moves it to
689
+ # this spot instead of piling up a duplicate entry -- same
690
+ # dedup-by-content_hash instinct _library['downloads'] already
691
+ # has, just for a list instead of a dict
692
+ playlist['items'] = [it for it in playlist['items'] if it['content_hash'] != content_hash]
693
+ playlist['items'].append({
694
+ 'content_hash': content_hash,
695
+ 'title': body.get('title') or None,
696
+ 'signer_pubkey': body.get('signer_pubkey') or None,
697
+ })
698
+ _save_library()
699
+ self._json({'playlist': playlist})
700
+
701
+ def _handle_playlist_remove(self, body):
702
+ playlist_id = body.get('playlist_id')
703
+ content_hash = body.get('content_hash')
704
+ if not playlist_id or not content_hash:
705
+ return self._json({'error': 'playlist_id and content_hash required'}, status=400)
706
+ with _lock:
707
+ playlist = next((p for p in _library['playlists'] if p['id'] == playlist_id), None)
708
+ if not playlist:
709
+ return self._json({'error': 'no such playlist'}, status=404)
710
+ playlist['items'] = [it for it in playlist['items'] if it['content_hash'] != content_hash]
711
+ _save_library()
712
+ self._json({'playlist': playlist})
713
+
714
+ def _handle_playlist_move(self, body):
715
+ playlist_id = body.get('playlist_id')
716
+ content_hash = body.get('content_hash')
717
+ direction = body.get('direction')
718
+ if not playlist_id or not content_hash or direction not in ('up', 'down'):
719
+ return self._json({'error': "playlist_id, content_hash, and direction ('up'|'down') required"},
720
+ status=400)
721
+ with _lock:
722
+ playlist = next((p for p in _library['playlists'] if p['id'] == playlist_id), None)
723
+ if not playlist:
724
+ return self._json({'error': 'no such playlist'}, status=404)
725
+ items = playlist['items']
726
+ idx = next((i for i, it in enumerate(items) if it['content_hash'] == content_hash), None)
727
+ if idx is None:
728
+ return self._json({'error': 'content_hash not in this playlist'}, status=404)
729
+ swap_with = idx - 1 if direction == 'up' else idx + 1
730
+ if 0 <= swap_with < len(items):
731
+ items[idx], items[swap_with] = items[swap_with], items[idx]
732
+ _save_library()
733
+ self._json({'playlist': playlist})
734
+
620
735
  def _handle_verify(self, body):
621
736
  """Re-checks an already-downloaded file against its own
622
737
  Merkle-committed chunk hashes without re-fetching it -- see
@@ -188,6 +188,18 @@ def cmd_host(args):
188
188
  # matching chunk data would otherwise get announced to the relay and
189
189
  # only fail later, in the background server thread
190
190
  all_leaves = {e['sha256']: node.load_leaves(args.archive_dir, e['sha256']) for e in entries}
191
+ # Bind the real listening socket now, before announcing anything --
192
+ # same reasoning as web_ui.py's _run_host_job and shell.py's do_host:
193
+ # a port already in use only used to surface once run_host_server got
194
+ # around to its own (later) bind, by which point every relay below
195
+ # had already been told this host is reachable at host_addr. Binding
196
+ # first and reusing the same socket in run_host_server (see sock=
197
+ # below) means a taken port fails loudly here instead, before a
198
+ # single relay ever hears about it.
199
+ try:
200
+ bound_sock = node.bind_host_port(args.port)
201
+ except OSError as e:
202
+ sys.exit(f'cannot bind port {args.port}: {e} — not announcing, nothing started')
191
203
  # one archive_dir, one merkle root, one commit -- computed once outside
192
204
  # the per-relay/per-entry loops below, not per file
193
205
  ott_status = node.ott_commit_status(args.archive_dir)
@@ -213,7 +225,7 @@ def cmd_host(args):
213
225
  kwargs={'use_tls': use_tls, 'ln_node': args.lightning_node},
214
226
  daemon=True).start()
215
227
  node.run_host_server(args.archive_dir, args.file, args.port, price=args.price,
216
- ln_node=args.lightning_node)
228
+ ln_node=args.lightning_node, sock=bound_sock)
217
229
 
218
230
 
219
231
  def cmd_discover(args):
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: weed-cli
3
- Version: 1.6.2
3
+ Version: 1.6.8
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
@@ -174,19 +174,28 @@ blind.
174
174
  ### Docker
175
175
 
176
176
  `Dockerfile.node` + `docker-compose.node.yml` package `web_ui.py` to run
177
- somewhere other than a laptop.
177
+ somewhere other than a laptop — alongside its own local discovery relay
178
+ (`Dockerfile.discovery-relay`), so there's always something real for it
179
+ to announce to and discover from out of the box.
178
180
 
179
181
  ```bash
180
182
  make node # build + run, http://127.0.0.1:8080
181
183
  make node-down # stop it — data persists
184
+ make node-shell # interactive weed shell, same container as the running node
182
185
  WEED_SHARE_DIR=~/Movies make node # mount a real directory of .ott archives at /share
183
- docker compose -f docker-compose.node.yml exec node python3 shell.py # interactive shell, same container
184
186
  docker compose -f docker-compose.node.yml exec node python3 weed.py discover # or a one-off CLI command
185
187
  ```
186
188
 
187
189
  - Identity key, reputation store, and library manifest all persist in a
188
190
  named volume (`node-data`, mounted at `$HOME=/data`) — one node keeps
189
191
  the same pubkey (and everything vouching for it) across restarts.
192
+ - The `relay` service is `node`'s default `WEED_RELAY` (`http://relay:9101`,
193
+ resolved over Compose's own network) — its events persist in their own
194
+ `relay-data` volume, and port 9101 is published to the host too, for
195
+ curling `/events` directly or pointing a non-Docker `weed`/shell session
196
+ at the exact same relay. Override `WEED_RELAY` (shell env or `.env`,
197
+ see `.env.sample`) to point at public infra instead, e.g. for a real
198
+ end-to-end test.
190
199
  - `/share` is a separate *bind* mount, not a named volume, so the actual
191
200
  `.ott` archives you're hosting are real files on the host you can see
192
201
  and manage directly. Point the web UI's Host form at `/share`.
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes