weed-cli 1.6.7__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.7
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
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "weed-cli"
7
- version = "1.6.7"
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
@@ -492,6 +499,7 @@ class Handler(BaseHTTPRequestHandler):
492
499
  'downloads': list(_library['downloads'].values()),
493
500
  'likes': list(_library['likes']),
494
501
  'subscriptions': list(_library['subscriptions']),
502
+ 'playlists': list(_library['playlists']),
495
503
  })
496
504
  if path.startswith('/api/download/'):
497
505
  job_id = path[len('/api/download/'):]
@@ -552,6 +560,12 @@ class Handler(BaseHTTPRequestHandler):
552
560
  '/api/host': self._handle_host, '/api/download': self._handle_download,
553
561
  '/api/like': self._handle_like, '/api/subscribe': self._handle_subscribe,
554
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,
555
569
  }
556
570
  handler = handlers.get(path)
557
571
  if not handler:
@@ -630,6 +644,94 @@ class Handler(BaseHTTPRequestHandler):
630
644
  _save_library()
631
645
  self._json({'result': result})
632
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
+
633
735
  def _handle_verify(self, body):
634
736
  """Re-checks an already-downloaded file against its own
635
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.7
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
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes