weed-cli 1.7.4__tar.gz → 1.8.0__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.7.4
3
+ Version: 1.8.0
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
@@ -20,6 +20,7 @@ Provides-Extra: dev
20
20
  Requires-Dist: pytest>=8.0; extra == "dev"
21
21
  Requires-Dist: pytest-cov>=5.0; extra == "dev"
22
22
  Requires-Dist: ruff>=0.5; extra == "dev"
23
+ Requires-Dist: pytest-playwright>=0.5; extra == "dev"
23
24
  Provides-Extra: dht
24
25
  Requires-Dist: kademlia>=2.2; extra == "dht"
25
26
  Provides-Extra: qr
@@ -232,7 +232,18 @@ def load_manifest_entries(archive_dir, file_name=None):
232
232
  """Every distinct file in the archive, not just one — find_manifest_entry
233
233
  collapses to a single entries[-1], which is exactly why `host <dir>` with
234
234
  no --file only ever served the single most-recently-added file out of a
235
- 45-video archive. Dedupes by name (last-write-wins, same convention)."""
235
+ 45-video archive. Dedupes by name (last-write-wins, same convention).
236
+
237
+ Only 'video' entries are returned. Hosting depends on chunk data
238
+ (load_leaves) and per-chunk byte math (entry['chunk_size']), and ott
239
+ only ever writes either for video-type entries — everything else
240
+ (photos, or any file whose extension ott's is_video() doesn't
241
+ recognize, which is also where a plain .mp3 lands, since ott only has
242
+ two types) has chunk_size: None and no .ott/chunks/<hash>.json at all.
243
+ Filtering here, the one function every hosting path (weed.py,
244
+ shell.py, web_ui.py) goes through, means one non-video file sitting
245
+ in an archive_dir no longer poison-pills hosting everything else in
246
+ it with 'no chunks file at ...'."""
236
247
  archive_dir = os.path.expanduser(archive_dir)
237
248
  manifest_path = os.path.join(archive_dir, '.ott', 'manifest.jsonl')
238
249
  if not os.path.exists(manifest_path):
@@ -244,8 +255,15 @@ def load_manifest_entries(archive_dir, file_name=None):
244
255
  by_name = {}
245
256
  for e in raw:
246
257
  by_name[e['name']] = e
247
- entries = list(by_name.values())
258
+ all_entries = list(by_name.values())
259
+ entries = [e for e in all_entries if e.get('type') == 'video']
248
260
  if not entries:
261
+ if all_entries:
262
+ n = len(all_entries)
263
+ sys.exit(f"no hostable video file found in {archive_dir}" +
264
+ (f" matching {file_name}" if file_name else "") +
265
+ f" — found {n} non-video entr{'y' if n == 1 else 'ies'} "
266
+ f"(only video files can be hosted; see ott's is_video())")
249
267
  sys.exit(f"no archived file found in {archive_dir}" + (f" matching {file_name}" if file_name else ""))
250
268
  return entries
251
269
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "weed-cli"
7
- version = "1.7.4"
7
+ version = "1.8.0"
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" }
@@ -36,6 +36,15 @@ dev = [
36
36
  "pytest>=8.0",
37
37
  "pytest-cov>=5.0",
38
38
  "ruff>=0.5",
39
+ # e2e (tests/e2e/) drives the real web UI in a real browser -- Python
40
+ # Playwright rather than the JS package, so `pip install -e .[dev]` is
41
+ # the only setup step; this repo otherwise has zero npm/node_modules
42
+ # footprint (web/ is vanilla JS, no build step -- see its own README
43
+ # note) and introducing one just for test tooling would be a bigger
44
+ # structural change than the tests themselves. Needs a Chromium binary
45
+ # too -- `playwright install chromium`, or point WEED_TEST_CHROMIUM at
46
+ # an existing one (see tests/e2e/conftest.py).
47
+ "pytest-playwright>=0.5",
39
48
  ]
40
49
  dht = [
41
50
  "kademlia>=2.2",
@@ -60,7 +69,7 @@ py-modules = [
60
69
  ]
61
70
 
62
71
  [tool.pytest.ini_options]
63
- testpaths = ["."]
72
+ testpaths = ["tests"]
64
73
  python_files = ["test_*.py"]
65
74
  python_functions = ["test_*"]
66
75
 
@@ -0,0 +1,115 @@
1
+ """
2
+ discovery_relay.py + node.discover()/publish()/unpublish() against a real
3
+ running relay (subprocess, real sockets) -- no mocking of HTTP or
4
+ signatures. Covers the actual trust model: relays store-and-forward
5
+ signed events and do nothing else, so "the network" only knows what a
6
+ client explicitly told a relay it happened to reach.
7
+ """
8
+ import time
9
+
10
+ import node
11
+ from poc_reputation import Identity
12
+
13
+
14
+ def test_publish_then_discover_round_trip(relay):
15
+ identity = Identity('alice')
16
+ result = node.publish(identity, relay, content_hash='c' * 64, title='My Video',
17
+ host_addr='127.0.0.1:9201')
18
+ assert result.get('ok') is True
19
+
20
+ results = node.discover([relay])
21
+ assert len(results) == 1
22
+ assert results[0]['content_hash'] == 'c' * 64
23
+ assert results[0]['title'] == 'My Video'
24
+ assert results[0]['signer_pubkey'] == identity.pubkey_hex()
25
+
26
+
27
+ def test_tampered_event_rejected(relay):
28
+ """The relay verifies signatures itself (node.py's own module
29
+ docstring: 'garbage in doesn't get stored') -- posting a payload that
30
+ doesn't match its signature must be refused, not silently stored."""
31
+ identity = Identity('alice')
32
+ event = identity.sign_event('publish', content_hash='c' * 64, title='Real Title',
33
+ host='127.0.0.1:9201', tunnel=None, ott_status=None)
34
+ event['payload']['title'] = 'Tampered Title' # mutate after signing
35
+
36
+ result = node.post_event(relay, event)
37
+ assert result.get('ok') is False
38
+
39
+ results = node.discover([relay])
40
+ assert results == []
41
+
42
+
43
+ def test_unpublish_delists(relay):
44
+ identity = Identity('alice')
45
+ node.publish(identity, relay, content_hash='c' * 64, title='My Video', host_addr='127.0.0.1:9201')
46
+ assert len(node.discover([relay])) == 1
47
+
48
+ node.unpublish(identity, relay, content_hash='c' * 64)
49
+ assert node.discover([relay]) == []
50
+
51
+
52
+ def test_republish_by_same_signer_replaces_not_duplicates(relay):
53
+ """Re-running `host` always signs a fresh ts -- discover() must key on
54
+ (content_hash, signer_pubkey) and keep only the newest, not grow one
55
+ entry per re-announcement forever."""
56
+ identity = Identity('alice')
57
+ node.publish(identity, relay, content_hash='c' * 64, title='v1', host_addr='127.0.0.1:9201')
58
+ time.sleep(0.01)
59
+ node.publish(identity, relay, content_hash='c' * 64, title='v2', host_addr='127.0.0.1:9202')
60
+
61
+ results = node.discover([relay])
62
+ assert len(results) == 1
63
+ assert results[0]['title'] == 'v2'
64
+ assert results[0]['host'] == '127.0.0.1:9202'
65
+
66
+
67
+ def test_two_signers_same_content_both_show_up(relay):
68
+ """Keyed on (content_hash, signer_pubkey), not content_hash alone --
69
+ two independent hosts of the same file are two separate listings."""
70
+ alice, bob = Identity('alice'), Identity('bob')
71
+ node.publish(alice, relay, content_hash='c' * 64, title='v', host_addr='127.0.0.1:9201')
72
+ node.publish(bob, relay, content_hash='c' * 64, title='v', host_addr='127.0.0.1:9202')
73
+
74
+ results = node.discover([relay])
75
+ assert {r['signer_pubkey'] for r in results} == {alice.pubkey_hex(), bob.pubkey_hex()}
76
+
77
+
78
+ def test_dead_relay_is_skipped_not_fatal(relay):
79
+ """One relay in the list being unreachable must not lose events that
80
+ genuinely live on a *different*, healthy relay -- discover() degrades,
81
+ it doesn't fail closed."""
82
+ identity = Identity('alice')
83
+ node.publish(identity, relay, content_hash='c' * 64, title='v', host_addr='127.0.0.1:9201')
84
+
85
+ dead_relay = 'http://127.0.0.1:1' # nothing listens here
86
+ results = node.discover([relay, dead_relay])
87
+ assert len(results) == 1
88
+ assert results[0]['content_hash'] == 'c' * 64
89
+
90
+
91
+ def test_content_only_on_a_relay_you_dont_query_is_invisible(relay, tmp_path):
92
+ """The actual answer to 'how long for full network coordination via
93
+ gossip': never, automatically -- relays never talk to each other.
94
+ Publishing to relay A and only ever querying relay B must not surface
95
+ the content, no matter what."""
96
+ import subprocess, sys, os
97
+ from testutil import free_port, wait_for_port
98
+
99
+ other_port = free_port()
100
+ env = dict(os.environ, WEED_RELAY_DATA=str(tmp_path / 'other_relay_events.jsonl'))
101
+ repo_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
102
+ proc = subprocess.Popen([sys.executable, os.path.join(repo_root, 'discovery_relay.py'), str(other_port)],
103
+ env=env, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
104
+ try:
105
+ other_relay = f'http://127.0.0.1:{other_port}'
106
+ assert wait_for_port('127.0.0.1', other_port)
107
+
108
+ identity = Identity('alice')
109
+ node.publish(identity, other_relay, content_hash='c' * 64, title='v', host_addr='127.0.0.1:9201')
110
+
111
+ assert node.discover([relay]) == [] # querying the *other* relay only
112
+ assert len(node.discover([other_relay])) == 1 # but it's really there
113
+ finally:
114
+ proc.terminate()
115
+ proc.wait(timeout=5)
@@ -0,0 +1,91 @@
1
+ """
2
+ node.py's manifest/chunk-loading logic -- pure filesystem + JSON, no
3
+ network, no servers. Includes a regression test for the real incident
4
+ this session: an .mp3 in the same archive_dir as a hosted video crashed
5
+ `host` entirely (see node.load_manifest_entries's own docstring).
6
+ """
7
+ import os
8
+
9
+ import pytest
10
+
11
+ import node
12
+ from testutil import make_fake_archive
13
+
14
+
15
+ def test_load_manifest_entries_single_video(tmp_path):
16
+ entry = make_fake_archive(tmp_path, name='good.mp4')
17
+ entries = node.load_manifest_entries(str(tmp_path))
18
+ assert [e['sha256'] for e in entries] == [entry['sha256']]
19
+
20
+
21
+ def test_load_manifest_entries_filters_non_video(tmp_path):
22
+ """The actual bug: an mp3 (typed 'image' by ott's extension-based
23
+ is_video()) sitting in the same archive_dir used to poison-pill
24
+ hosting the whole directory -- host <dir> with no --file should just
25
+ skip it and host the real video."""
26
+ video = make_fake_archive(tmp_path, name='good.mp4')
27
+ make_fake_archive(tmp_path, name='song.mp3', video=False)
28
+
29
+ entries = node.load_manifest_entries(str(tmp_path))
30
+ assert [e['sha256'] for e in entries] == [video['sha256']]
31
+
32
+
33
+ def test_load_manifest_entries_explicit_non_video_file_errors_clearly(tmp_path):
34
+ make_fake_archive(tmp_path, name='good.mp4')
35
+ make_fake_archive(tmp_path, name='song.mp3', video=False)
36
+
37
+ with pytest.raises(SystemExit, match='no hostable video file found'):
38
+ node.load_manifest_entries(str(tmp_path), 'song.mp3')
39
+
40
+
41
+ def test_load_manifest_entries_no_manifest_at_all(tmp_path):
42
+ with pytest.raises(SystemExit, match='no .ott/manifest.jsonl'):
43
+ node.load_manifest_entries(str(tmp_path))
44
+
45
+
46
+ def test_load_manifest_entries_dedupes_by_name_last_write_wins(tmp_path):
47
+ """Two manifest lines for the same file name (re-added after a real
48
+ edit) should collapse to the newer entry, not double-list it."""
49
+ os.makedirs(os.path.join(tmp_path, '.ott'), exist_ok=True)
50
+ manifest = os.path.join(tmp_path, '.ott', 'manifest.jsonl')
51
+ old = {'sha256': 'a' * 64, 'name': 'clip.mp4', 'orig_path': 'clip.mp4',
52
+ 'last_path': str(tmp_path / 'clip.mp4'), 'size': 1, 'added': '2020-01-01T00:00:00Z',
53
+ 'type': 'video', 'n_chunks': 1, 'chunk_size': 65536}
54
+ new = {**old, 'sha256': 'b' * 64, 'added': '2026-01-01T00:00:00Z'}
55
+ with open(manifest, 'w') as f:
56
+ f.write('%s\n%s\n' % (__import__('json').dumps(old), __import__('json').dumps(new)))
57
+
58
+ entries = node.load_manifest_entries(str(tmp_path))
59
+ assert len(entries) == 1
60
+ assert entries[0]['sha256'] == 'b' * 64
61
+
62
+
63
+ def test_load_leaves_round_trips_real_chunks(tmp_path):
64
+ entry = make_fake_archive(tmp_path, name='good.mp4', size=200_000, chunk_size=65_536)
65
+ leaves = node.load_leaves(str(tmp_path), entry['sha256'])
66
+ assert len(leaves) == entry['n_chunks']
67
+ assert len(leaves) > 1 # 200_000 bytes / 65_536 chunk_size genuinely spans multiple chunks
68
+
69
+
70
+ def test_load_leaves_missing_chunks_file_errors_clearly(tmp_path):
71
+ entry = make_fake_archive(tmp_path, name='good.mp4', video=False)
72
+ with pytest.raises(SystemExit, match='no chunks file at'):
73
+ node.load_leaves(str(tmp_path), entry['sha256'])
74
+
75
+
76
+ def test_resolve_file_path_prefers_last_path_when_it_exists(tmp_path):
77
+ entry = make_fake_archive(tmp_path, name='good.mp4')
78
+ resolved = node.resolve_file_path(entry, str(tmp_path))
79
+ assert resolved == entry['last_path']
80
+ assert os.path.exists(resolved)
81
+
82
+
83
+ def test_resolve_file_path_falls_back_when_last_path_is_stale(tmp_path):
84
+ """last_path is recorded at archive time on whatever machine ran
85
+ `ott add` -- trusting it unconditionally breaks the moment archive_dir
86
+ is the same content mounted somewhere else (see node.py's own
87
+ docstring for the real Docker-bind-mount incident this guards)."""
88
+ entry = make_fake_archive(tmp_path, name='good.mp4')
89
+ entry['last_path'] = '/nonexistent/path/on/a/different/machine/good.mp4'
90
+ resolved = node.resolve_file_path(entry, str(tmp_path))
91
+ assert resolved == os.path.join(str(tmp_path), 'good.mp4')
@@ -0,0 +1,127 @@
1
+ """
2
+ web_ui.py's REST API against a real, isolated WebUIServer instance (see
3
+ conftest.web_server) -- real HTTP requests via the stdlib, same as the
4
+ rest of this codebase's own "no new dependency" convention. Every
5
+ identity/library/hosts file this touches is redirected into tmp_path by
6
+ the isolated_paths fixture web_server depends on; nothing here can ever
7
+ read or write a real ~/.weed_* file.
8
+ """
9
+ from testutil import http_get_json, http_post_json
10
+
11
+
12
+ def test_whoami_and_empty_library(web_server):
13
+ who = http_get_json(f'{web_server}/api/whoami')
14
+ assert len(who['pubkey']) == 64 # 32-byte Ed25519 pubkey, hex-encoded
15
+
16
+ lib = http_get_json(f'{web_server}/api/library')
17
+ assert lib == {'downloads': [], 'likes': [], 'subscriptions': [], 'playlists': [], 'history': []}
18
+
19
+
20
+ def test_like_is_idempotent(web_server):
21
+ status, resp = http_post_json(f'{web_server}/api/like', {'content_hash': 'c' * 64})
22
+ assert status == 200
23
+ for _ in range(3):
24
+ http_post_json(f'{web_server}/api/like', {'content_hash': 'c' * 64})
25
+ lib = http_get_json(f'{web_server}/api/library')
26
+ assert lib['likes'] == ['c' * 64]
27
+
28
+
29
+ def test_like_requires_content_hash(web_server):
30
+ status, resp = http_post_json(f'{web_server}/api/like', {})
31
+ assert status == 400
32
+ assert 'content_hash' in resp['error']
33
+
34
+
35
+ def test_playlist_create_add_reorder_remove_delete(web_server):
36
+ status, resp = http_post_json(f'{web_server}/api/playlists/create', {'name': 'My List'})
37
+ assert status == 200
38
+ playlist_id = resp['playlist']['id']
39
+
40
+ for h in ('a' * 64, 'b' * 64):
41
+ status, resp = http_post_json(f'{web_server}/api/playlists/add', {
42
+ 'playlist_id': playlist_id, 'content_hash': h, 'title': h[:8],
43
+ })
44
+ assert status == 200
45
+
46
+ lib = http_get_json(f'{web_server}/api/library')
47
+ pl = next(p for p in lib['playlists'] if p['id'] == playlist_id)
48
+ assert [it['content_hash'] for it in pl['items']] == ['a' * 64, 'b' * 64]
49
+
50
+ status, _ = http_post_json(f'{web_server}/api/playlists/reorder', {
51
+ 'playlist_id': playlist_id, 'order': ['b' * 64, 'a' * 64],
52
+ })
53
+ assert status == 200
54
+ lib = http_get_json(f'{web_server}/api/library')
55
+ pl = next(p for p in lib['playlists'] if p['id'] == playlist_id)
56
+ assert [it['content_hash'] for it in pl['items']] == ['b' * 64, 'a' * 64]
57
+
58
+ status, _ = http_post_json(f'{web_server}/api/playlists/remove', {
59
+ 'playlist_id': playlist_id, 'content_hash': 'a' * 64,
60
+ })
61
+ assert status == 200
62
+ lib = http_get_json(f'{web_server}/api/library')
63
+ pl = next(p for p in lib['playlists'] if p['id'] == playlist_id)
64
+ assert [it['content_hash'] for it in pl['items']] == ['b' * 64]
65
+
66
+ status, _ = http_post_json(f'{web_server}/api/playlists/delete', {'playlist_id': playlist_id})
67
+ assert status == 200
68
+ lib = http_get_json(f'{web_server}/api/library')
69
+ assert lib['playlists'] == []
70
+
71
+
72
+ def test_play_requires_content_hash(web_server):
73
+ status, resp = http_post_json(f'{web_server}/api/play', {})
74
+ assert status == 400
75
+
76
+
77
+ def test_play_bumps_count_and_history_for_a_known_download(web_server):
78
+ import web_ui
79
+ web_ui._library['downloads']['c' * 64] = {
80
+ 'content_hash': 'c' * 64, 'job_id': 'job1', 'path': '/tmp/x.mp4',
81
+ 'title': 'X', 'downloaded_at': 0, 'size': 1, 'bps': 1, 'signer_pubkey': None,
82
+ }
83
+
84
+ status, resp = http_post_json(f'{web_server}/api/play', {'content_hash': 'c' * 64, 'title': 'X'})
85
+ assert status == 200
86
+ assert resp['play_count'] == 1
87
+ assert resp['last_played'] is not None
88
+
89
+ status, resp = http_post_json(f'{web_server}/api/play', {'content_hash': 'c' * 64, 'title': 'X'})
90
+ assert resp['play_count'] == 2
91
+
92
+ lib = http_get_json(f'{web_server}/api/library')
93
+ rec = next(d for d in lib['downloads'] if d['content_hash'] == 'c' * 64)
94
+ assert rec['play_count'] == 2
95
+ assert len(lib['history']) == 2
96
+ assert lib['history'][0]['content_hash'] == 'c' * 64 # newest first
97
+
98
+
99
+ def test_play_for_unknown_content_hash_still_logs_history_but_no_count(web_server):
100
+ """A play_count only exists on a downloads record -- playing something
101
+ that isn't (yet, or ever) in _library['downloads'] shouldn't 500, it
102
+ just can't report a play_count."""
103
+ status, resp = http_post_json(f'{web_server}/api/play', {'content_hash': 'z' * 64, 'title': 'Ghost'})
104
+ assert status == 200
105
+ assert resp['play_count'] is None
106
+
107
+ lib = http_get_json(f'{web_server}/api/library')
108
+ assert len(lib['history']) == 1
109
+ assert lib['history'][0]['title'] == 'Ghost'
110
+
111
+
112
+ def test_cross_origin_post_rejected(web_server):
113
+ """No auth at all by design (see web_ui.py's module docstring) --
114
+ Origin-checking is the only thing standing between this and any other
115
+ open tab silently POSTing here. A request claiming a different Origin
116
+ must be refused."""
117
+ status, resp = http_post_json(f'{web_server}/api/like', {'content_hash': 'c' * 64},
118
+ headers={'Origin': 'http://evil.example'})
119
+ assert status == 403
120
+
121
+
122
+ def test_request_with_no_origin_header_is_allowed(web_server):
123
+ """curl / server-to-server / direct API use never sets Origin -- only
124
+ a real cross-site browser request does, so a missing header is let
125
+ through (see web_ui.py's _check_origin docstring)."""
126
+ status, resp = http_post_json(f'{web_server}/api/like', {'content_hash': 'd' * 64})
127
+ assert status == 200
@@ -0,0 +1,114 @@
1
+ """
2
+ Plain (non-fixture) test helpers, deliberately NOT named conftest.py --
3
+ pytest auto-imports every conftest.py it finds under a module name derived
4
+ from its path, and tests/e2e/conftest.py needing helpers from
5
+ tests/conftest.py hits a real name collision that way (both would want the
6
+ bare module name "conftest" under this repo's flat, __init__.py-less
7
+ layout). A uniquely-named module both conftest.py files can import from
8
+ sidesteps that entirely.
9
+ """
10
+ import http.client
11
+ import hashlib
12
+ import json
13
+ import os
14
+ import socket
15
+ import time
16
+
17
+ from ott import chunk_hashes, merkle_root
18
+
19
+
20
+ def free_port():
21
+ """Ask the OS for a genuinely free port instead of guessing one and
22
+ racing every other test (and anything else on the machine) for it."""
23
+ s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
24
+ s.bind(('127.0.0.1', 0))
25
+ port = s.getsockname()[1]
26
+ s.close()
27
+ return port
28
+
29
+
30
+ def wait_for_port(host, port, timeout=5.0):
31
+ deadline = time.time() + timeout
32
+ while time.time() < deadline:
33
+ try:
34
+ with socket.create_connection((host, port), timeout=0.2):
35
+ return True
36
+ except OSError:
37
+ time.sleep(0.05)
38
+ return False
39
+
40
+
41
+ def http_get(url):
42
+ conn_host, conn_port, path = _split_url(url)
43
+ conn = http.client.HTTPConnection(conn_host, conn_port, timeout=5)
44
+ try:
45
+ conn.request('GET', path)
46
+ resp = conn.getresponse()
47
+ return resp.read().decode()
48
+ finally:
49
+ conn.close()
50
+
51
+
52
+ def http_get_json(url):
53
+ return json.loads(http_get(url))
54
+
55
+
56
+ def http_post_json(url, body, headers=None):
57
+ conn_host, conn_port, path = _split_url(url)
58
+ conn = http.client.HTTPConnection(conn_host, conn_port, timeout=5)
59
+ try:
60
+ h = {'Content-Type': 'application/json'}
61
+ h.update(headers or {})
62
+ conn.request('POST', path, body=json.dumps(body), headers=h)
63
+ resp = conn.getresponse()
64
+ return resp.status, json.loads(resp.read().decode())
65
+ finally:
66
+ conn.close()
67
+
68
+
69
+ def _split_url(url):
70
+ # http.client wants (host, port) and a bare path, not a full URL
71
+ assert url.startswith('http://')
72
+ rest = url[len('http://'):]
73
+ hostport, _, path = rest.partition('/')
74
+ host, _, port = hostport.partition(':')
75
+ return host, int(port), '/' + path
76
+
77
+
78
+ def make_fake_archive(archive_dir, name='clip.mp4', size=200_000, chunk_size=65_536, video=True):
79
+ """Hand-writes a minimal but real .ott/manifest.jsonl + chunks file --
80
+ real sha256 chunk hashes and a real merkle root via the same ott
81
+ helpers node.py itself calls, just skipping ott's own add/stage/commit
82
+ workflow (irrelevant to what node.py/web_ui.py actually read: the
83
+ final manifest.jsonl + chunks/<hash>.json shape). video=False produces
84
+ an 'image'-typed entry with no chunks file, for regression-testing the
85
+ mp3/photo-mixed-into-an-archive fix (load_manifest_entries filtering
86
+ to video-only)."""
87
+ os.makedirs(archive_dir, exist_ok=True)
88
+ ott_dir = os.path.join(archive_dir, '.ott')
89
+ os.makedirs(os.path.join(ott_dir, 'chunks'), exist_ok=True)
90
+ file_path = os.path.join(archive_dir, name)
91
+ with open(file_path, 'wb') as f:
92
+ f.write(os.urandom(size))
93
+
94
+ if video:
95
+ chunks = chunk_hashes(file_path, chunk_size)
96
+ digest = merkle_root(chunks)
97
+ entry = {
98
+ 'sha256': digest, 'name': name, 'orig_path': name, 'last_path': file_path,
99
+ 'size': size, 'added': '2026-01-01T00:00:00Z', 'type': 'video',
100
+ 'n_chunks': len(chunks), 'chunk_size': chunk_size,
101
+ }
102
+ with open(os.path.join(ott_dir, 'chunks', f'{digest}.json'), 'w') as f:
103
+ json.dump(chunks, f)
104
+ else:
105
+ digest = hashlib.sha256(open(file_path, 'rb').read()).hexdigest()
106
+ entry = {
107
+ 'sha256': digest, 'name': name, 'orig_path': name, 'last_path': file_path,
108
+ 'size': size, 'added': '2026-01-01T00:00:00Z', 'type': 'image',
109
+ 'n_chunks': 1, 'chunk_size': None,
110
+ }
111
+
112
+ with open(os.path.join(ott_dir, 'manifest.jsonl'), 'a') as f:
113
+ f.write(json.dumps(entry) + '\n')
114
+ return entry
@@ -45,6 +45,11 @@ WEB_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'web')
45
45
  DEFAULT_RELAY = os.environ.get('WEED_RELAY', 'http://127.0.0.1:9101')
46
46
  DEFAULT_TUNNEL = os.environ.get('WEED_TUNNEL')
47
47
  LIBRARY_PATH = os.path.expanduser('~/.weed_library.json')
48
+ # play history is a log, not a set -- it grows forever otherwise (every
49
+ # playlist "next" and every re-watch appends). This caps ~/.weed_library.json
50
+ # from growing unbounded while still keeping far more history than anyone
51
+ # is realistically going to scroll back through.
52
+ MAX_HISTORY = 500
48
53
  # every real POST body here is a handful of JSON fields (hashes, URLs,
49
54
  # titles) -- no endpoint ever legitimately needs anywhere near this much,
50
55
  # it's purely a cap against a client claiming a huge Content-Length and
@@ -89,7 +94,8 @@ class _JobStdout:
89
94
  return getattr(self._real, name)
90
95
 
91
96
 
92
- sys.stdout = _JobStdout(sys.stdout)
97
+ _job_stdout = _JobStdout(sys.stdout)
98
+ sys.stdout = _job_stdout
93
99
 
94
100
 
95
101
  @contextlib.contextmanager
@@ -102,13 +108,27 @@ def _quiet():
102
108
  (...)" lines (exactly the detail that explains *why* a download
103
109
  failed) used to get captured here and then thrown away unread, so the
104
110
  web UI only ever showed "no candidate host passed the possession
105
- challenge" with zero indication of which candidate failed how."""
111
+ challenge" with zero indication of which candidate failed how.
112
+
113
+ Re-asserts sys.stdout = _job_stdout on every call rather than trusting
114
+ it's still set from module-import time, and manipulates _job_stdout
115
+ directly rather than via sys.stdout -- something else with its own
116
+ reason to reassign sys.stdout globally afterward (pytest's own output
117
+ capturing is the concrete case that surfaced this: it swaps sys.stdout
118
+ to its own capture object between tests, silently detaching
119
+ _JobStdout from the global) would otherwise make this line crash with
120
+ an AttributeError on whatever replaced it, instead of muting output
121
+ like it's supposed to. Nothing in this app's own normal run path ever
122
+ reassigns sys.stdout again after the module-level line above, so this
123
+ is a no-op there -- purely a defensive re-assert."""
124
+ if sys.stdout is not _job_stdout:
125
+ sys.stdout = _job_stdout
106
126
  buf = io.StringIO()
107
- sys.stdout._local.buf = buf
127
+ _job_stdout._local.buf = buf
108
128
  try:
109
129
  yield buf
110
130
  finally:
111
- sys.stdout._local.buf = None
131
+ _job_stdout._local.buf = None
112
132
 
113
133
 
114
134
  def _with_captured_detail(msg, captured):
@@ -130,9 +150,12 @@ _lock = threading.Lock()
130
150
  # business seeing how you've grouped your own downloads -- a list of
131
151
  # {id, name, items: [{content_hash, title, signer_pubkey}, ...]}, id
132
152
  # 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': []}
153
+ # while its name changes underneath it. history is a chronological log of
154
+ # plays (newest last), each {content_hash, title, played_at} -- separate
155
+ # from downloads' own play_count/last_played (see _handle_play) since
156
+ # those two are aggregates per content_hash, while this is the actual
157
+ # per-play timeline. All access goes through _lock, same as _hosts/_jobs.
158
+ _library = {'downloads': {}, 'likes': [], 'subscriptions': [], 'playlists': [], 'history': []}
136
159
 
137
160
  # what to (re-)host on startup -- _hosts above is pure in-memory runtime
138
161
  # state, so a restart (a fresh `make node`, a Docker container recreated
@@ -252,6 +275,7 @@ def _load_library():
252
275
  'likes': data.get('likes') or [],
253
276
  'subscriptions': data.get('subscriptions') or [],
254
277
  'playlists': data.get('playlists') or [],
278
+ 'history': data.get('history') or [],
255
279
  }
256
280
  except (FileNotFoundError, json.JSONDecodeError):
257
281
  pass
@@ -500,6 +524,9 @@ class Handler(BaseHTTPRequestHandler):
500
524
  'likes': list(_library['likes']),
501
525
  'subscriptions': list(_library['subscriptions']),
502
526
  'playlists': list(_library['playlists']),
527
+ # newest first -- the only order a "recently played" list
528
+ # is ever actually consumed in
529
+ 'history': list(reversed(_library['history'])),
503
530
  })
504
531
  if path.startswith('/api/download/'):
505
532
  job_id = path[len('/api/download/'):]
@@ -559,7 +586,7 @@ class Handler(BaseHTTPRequestHandler):
559
586
  handlers = {
560
587
  '/api/host': self._handle_host, '/api/download': self._handle_download,
561
588
  '/api/like': self._handle_like, '/api/subscribe': self._handle_subscribe,
562
- '/api/verify': self._handle_verify,
589
+ '/api/verify': self._handle_verify, '/api/play': self._handle_play,
563
590
  '/api/playlists/create': self._handle_playlist_create,
564
591
  '/api/playlists/rename': self._handle_playlist_rename,
565
592
  '/api/playlists/delete': self._handle_playlist_delete,
@@ -644,6 +671,32 @@ class Handler(BaseHTTPRequestHandler):
644
671
  _save_library()
645
672
  self._json({'result': result})
646
673
 
674
+ def _handle_play(self, body):
675
+ """Called once per openPlayer() on the frontend (Discover's ▶ Play,
676
+ a Downloads row, a playlist item, or onPlayerEnded's own auto-
677
+ advance) -- not inferred from /api/stream's byte-range requests,
678
+ which fire many times per single watch (seeking, buffering) and
679
+ would massively overcount. No relay/event involved, unlike
680
+ like/subscribe above -- what you've watched is purely local, same
681
+ reasoning as playlists' own docstring on why those aren't gossiped
682
+ either."""
683
+ content_hash = body.get('content_hash')
684
+ if not content_hash:
685
+ return self._json({'error': 'content_hash required'}, status=400)
686
+ now = time.time()
687
+ with _lock:
688
+ rec = _library['downloads'].get(content_hash)
689
+ title = body.get('title') or (rec.get('title') if rec else None)
690
+ play_count = None
691
+ if rec is not None:
692
+ rec['play_count'] = rec.get('play_count', 0) + 1
693
+ rec['last_played'] = now
694
+ play_count = rec['play_count']
695
+ _library['history'].append({'content_hash': content_hash, 'title': title, 'played_at': now})
696
+ _library['history'] = _library['history'][-MAX_HISTORY:]
697
+ _save_library()
698
+ self._json({'play_count': play_count, 'last_played': now})
699
+
647
700
  def _handle_playlist_create(self, body):
648
701
  name = (body.get('name') or '').strip()
649
702
  if not name:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: weed-cli
3
- Version: 1.7.4
3
+ Version: 1.8.0
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
@@ -20,6 +20,7 @@ Provides-Extra: dev
20
20
  Requires-Dist: pytest>=8.0; extra == "dev"
21
21
  Requires-Dist: pytest-cov>=5.0; extra == "dev"
22
22
  Requires-Dist: ruff>=0.5; extra == "dev"
23
+ Requires-Dist: pytest-playwright>=0.5; extra == "dev"
23
24
  Provides-Extra: dht
24
25
  Requires-Dist: kademlia>=2.2; extra == "dht"
25
26
  Provides-Extra: qr
@@ -10,6 +10,10 @@ shell.py
10
10
  tunnel_relay.py
11
11
  web_ui.py
12
12
  weed.py
13
+ tests/test_discovery_relay.py
14
+ tests/test_node_manifest.py
15
+ tests/test_web_ui_api.py
16
+ tests/testutil.py
13
17
  weed_cli.egg-info/PKG-INFO
14
18
  weed_cli.egg-info/SOURCES.txt
15
19
  weed_cli.egg-info/dependency_links.txt
@@ -5,6 +5,7 @@ cryptography>=41.0
5
5
  pytest>=8.0
6
6
  pytest-cov>=5.0
7
7
  ruff>=0.5
8
+ pytest-playwright>=0.5
8
9
 
9
10
  [dht]
10
11
  kademlia>=2.2
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
File without changes