weed-cli 2.7.1__tar.gz → 2.7.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.
Files changed (32) hide show
  1. {weed_cli-2.7.1 → weed_cli-2.7.2}/PKG-INFO +21 -4
  2. {weed_cli-2.7.1 → weed_cli-2.7.2}/README.md +20 -3
  3. {weed_cli-2.7.1 → weed_cli-2.7.2}/discovery_relay.py +30 -2
  4. {weed_cli-2.7.1 → weed_cli-2.7.2}/node.py +128 -9
  5. {weed_cli-2.7.1 → weed_cli-2.7.2}/pyproject.toml +1 -1
  6. {weed_cli-2.7.1 → weed_cli-2.7.2}/shell.py +2 -1
  7. {weed_cli-2.7.1 → weed_cli-2.7.2}/tests/test_discovery_relay.py +31 -0
  8. {weed_cli-2.7.1 → weed_cli-2.7.2}/tests/test_kodi_addon.py +43 -2
  9. weed_cli-2.7.2/tests/test_node_manifest.py +201 -0
  10. {weed_cli-2.7.1 → weed_cli-2.7.2}/tests/test_web_ui_api.py +33 -0
  11. {weed_cli-2.7.1 → weed_cli-2.7.2}/tests/testutil.py +3 -2
  12. {weed_cli-2.7.1 → weed_cli-2.7.2}/web_ui.py +70 -12
  13. {weed_cli-2.7.1 → weed_cli-2.7.2}/weed.py +2 -1
  14. {weed_cli-2.7.1 → weed_cli-2.7.2}/weed_cli.egg-info/PKG-INFO +21 -4
  15. weed_cli-2.7.1/tests/test_node_manifest.py +0 -114
  16. {weed_cli-2.7.1 → weed_cli-2.7.2}/LICENSE +0 -0
  17. {weed_cli-2.7.1 → weed_cli-2.7.2}/dht.py +0 -0
  18. {weed_cli-2.7.1 → weed_cli-2.7.2}/lightning_settle.py +0 -0
  19. {weed_cli-2.7.1 → weed_cli-2.7.2}/poc_reputation.py +0 -0
  20. {weed_cli-2.7.1 → weed_cli-2.7.2}/setup.cfg +0 -0
  21. {weed_cli-2.7.1 → weed_cli-2.7.2}/tests/test_dht.py +0 -0
  22. {weed_cli-2.7.1 → weed_cli-2.7.2}/tests/test_host_live_reload.py +0 -0
  23. {weed_cli-2.7.1 → weed_cli-2.7.2}/tests/test_orbit_audio.py +0 -0
  24. {weed_cli-2.7.1 → weed_cli-2.7.2}/tests/test_orbit_ws.py +0 -0
  25. {weed_cli-2.7.1 → weed_cli-2.7.2}/tests/test_timing_challenge.py +0 -0
  26. {weed_cli-2.7.1 → weed_cli-2.7.2}/tests/test_tunnel_failover.py +0 -0
  27. {weed_cli-2.7.1 → weed_cli-2.7.2}/tunnel_relay.py +0 -0
  28. {weed_cli-2.7.1 → weed_cli-2.7.2}/weed_cli.egg-info/SOURCES.txt +0 -0
  29. {weed_cli-2.7.1 → weed_cli-2.7.2}/weed_cli.egg-info/dependency_links.txt +0 -0
  30. {weed_cli-2.7.1 → weed_cli-2.7.2}/weed_cli.egg-info/entry_points.txt +0 -0
  31. {weed_cli-2.7.1 → weed_cli-2.7.2}/weed_cli.egg-info/requires.txt +0 -0
  32. {weed_cli-2.7.1 → weed_cli-2.7.2}/weed_cli.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: weed-cli
3
- Version: 2.7.1
3
+ Version: 2.7.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
@@ -385,9 +385,26 @@ docker compose -f docker-compose.node.yml exec node python3 weed.py discover #
385
385
  Python changes (`web_ui.py`, `node.py`) still need `make node`.
386
386
  - An entry's `last_path` (recorded at `ott add` time, on whatever
387
387
  machine ran it) is only trusted if it exists on disk; otherwise the
388
- node falls back to the given archive directory. This matters the
389
- moment the same content is mounted somewhere else than where it was
390
- archived — e.g. `/share` here vs. wherever it originally lived.
388
+ node looks in the given archive directory at the entry's own
389
+ archive-relative path. This matters the moment the same content is
390
+ mounted somewhere else than where it was archived — e.g. `/share`
391
+ here vs. wherever it originally lived.
392
+ - Subdirectories are fine: `ott add` walks a folder, records where
393
+ each file sits relative to the archive root, and `host` serves the
394
+ whole tree from one `.ott/`. Two files with the same name in
395
+ different subdirectories are two files; `--file Live/set.mkv` picks
396
+ one by its relative path. A folder dropped on the web UI's Host tab
397
+ is uploaded file by file into the same subdirectories.
398
+ Files moved into a folder after archiving are found too: `ott
399
+ fix-renames` (or `ott reindex`) records the new place, and the node
400
+ looks for a file under the archive by the tail of that path, so the
401
+ host machine's absolute path not existing inside a container doesn't
402
+ matter.
403
+ The folder travels with the listing (a `folder` field on the publish
404
+ event), so Discover shows it as a crumb ahead of the title with a
405
+ folder filter, a download keeps it for the Downloads tab's folder
406
+ chips and sort, the Active hosts table counts files and folders, and
407
+ the Kodi add-on has a **Folders** screen that walks the tree.
391
408
 
392
409
  ## Core mechanisms
393
410
 
@@ -353,9 +353,26 @@ docker compose -f docker-compose.node.yml exec node python3 weed.py discover #
353
353
  Python changes (`web_ui.py`, `node.py`) still need `make node`.
354
354
  - An entry's `last_path` (recorded at `ott add` time, on whatever
355
355
  machine ran it) is only trusted if it exists on disk; otherwise the
356
- node falls back to the given archive directory. This matters the
357
- moment the same content is mounted somewhere else than where it was
358
- archived — e.g. `/share` here vs. wherever it originally lived.
356
+ node looks in the given archive directory at the entry's own
357
+ archive-relative path. This matters the moment the same content is
358
+ mounted somewhere else than where it was archived — e.g. `/share`
359
+ here vs. wherever it originally lived.
360
+ - Subdirectories are fine: `ott add` walks a folder, records where
361
+ each file sits relative to the archive root, and `host` serves the
362
+ whole tree from one `.ott/`. Two files with the same name in
363
+ different subdirectories are two files; `--file Live/set.mkv` picks
364
+ one by its relative path. A folder dropped on the web UI's Host tab
365
+ is uploaded file by file into the same subdirectories.
366
+ Files moved into a folder after archiving are found too: `ott
367
+ fix-renames` (or `ott reindex`) records the new place, and the node
368
+ looks for a file under the archive by the tail of that path, so the
369
+ host machine's absolute path not existing inside a container doesn't
370
+ matter.
371
+ The folder travels with the listing (a `folder` field on the publish
372
+ event), so Discover shows it as a crumb ahead of the title with a
373
+ folder filter, a download keeps it for the Downloads tab's folder
374
+ chips and sort, the Active hosts table counts files and folders, and
375
+ the Kodi add-on has a **Folders** screen that walks the tree.
359
376
 
360
377
  ## Core mechanisms
361
378
 
@@ -55,7 +55,15 @@ MAX_BODY_SIZE = 256 * 1024
55
55
  # free -- but that's a fundamentally bigger attack (unlimited identities)
56
56
  # than "spam with one key," and defending against it needs proof-of-
57
57
  # work/stake, a bigger design decision than this PoC calls for.
58
- MAX_EVENTS_PER_SIGNER = 200
58
+ # The cap counts *distinct* things a signer has said: a re-announce of a
59
+ # file (every host start posts one per file) replaces that signer's
60
+ # older publish/unpublish for the same content_hash rather than adding
61
+ # to the pile, so a host with N files holds N events here however often
62
+ # it restarts. Sized for a real library, not a demo -- 200 with the old
63
+ # additive counting meant a 515-file host only ever had its last 200
64
+ # announcements listed (Ryan: "Host shows 515 files in 18 folders, but I
65
+ # only see a subset of those in Discover").
66
+ MAX_EVENTS_PER_SIGNER = 5000
59
67
 
60
68
 
61
69
  def _load_events():
@@ -90,6 +98,26 @@ def _rewrite_events_file():
90
98
  os.replace(tmp, DATA_PATH)
91
99
 
92
100
 
101
+ def _supersede(event):
102
+ """Caller must hold _lock. A signer's newer publish/unpublish for a
103
+ content_hash replaces their older one(s) for it; a stale post (older
104
+ ts than what's held) is the one dropped instead. Returns False when
105
+ the incoming event is the stale one and should not be stored."""
106
+ p = event['payload']
107
+ if p.get('type') not in ('publish', 'unpublish') or not p.get('content_hash') or not p.get('signer_pubkey'):
108
+ return True
109
+ same = [e for e in _events if e['payload'].get('type') in ('publish', 'unpublish')
110
+ and e['payload'].get('content_hash') == p['content_hash']
111
+ and e['payload'].get('signer_pubkey') == p['signer_pubkey']]
112
+ if any(e['payload'].get('ts', 0) > p.get('ts', 0) for e in same):
113
+ return False
114
+ if same:
115
+ for e in same:
116
+ _events.remove(e)
117
+ _rewrite_events_file()
118
+ return True
119
+
120
+
93
121
  def _evict_oldest_for_signer(signer_pubkey):
94
122
  """Caller must hold _lock. Drops that signer's own oldest event once
95
123
  they're at the cap, making room for the new one -- an active
@@ -133,7 +161,7 @@ class RelayHandler(BaseHTTPRequestHandler):
133
161
  return
134
162
  eid = attestation_id(event)
135
163
  with _lock:
136
- if not any(attestation_id(e) == eid for e in _events):
164
+ if not any(attestation_id(e) == eid for e in _events) and _supersede(event):
137
165
  _evict_oldest_for_signer(event['payload'].get('signer_pubkey'))
138
166
  _events.append(event)
139
167
  _append_event(event)
@@ -214,6 +214,29 @@ class LineReader:
214
214
  return line.decode().strip()
215
215
 
216
216
 
217
+ def entry_rel_path(entry):
218
+ """Where the file sits inside its archive: orig_path (ott records it
219
+ relative to the archive root, so a file added from a subdirectory
220
+ keeps that subdirectory) when it's a plain relative path, else the
221
+ bare name. Anything that could climb out of the archive (absolute,
222
+ or a '..' segment, which ott's own older entries could carry before
223
+ orig_path was anchored to the root) falls back to the name -- this
224
+ is joined onto archive_dir by resolve_file_path."""
225
+ raw = (entry.get('orig_path') or '').replace('\\', '/')
226
+ rel = raw.strip('/')
227
+ if not rel or raw.startswith('/') or os.path.isabs(raw) or '..' in rel.split('/'):
228
+ return entry['name']
229
+ return rel
230
+
231
+
232
+ def _entry_matches(entry, file_name):
233
+ """--file / file_name matches the bare name or the archive-relative
234
+ path, so `--file Live/set.mkv` picks one of two files that share a
235
+ name in different subdirectories."""
236
+ wanted = file_name.replace('\\', '/').strip('/')
237
+ return entry['name'] == wanted or entry_rel_path(entry) == wanted
238
+
239
+
217
240
  def find_manifest_entry(archive_dir, file_name=None):
218
241
  archive_dir = os.path.expanduser(archive_dir) # os.path.join never expands ~, it stays literal
219
242
  manifest_path = os.path.join(archive_dir, '.ott', 'manifest.jsonl')
@@ -222,7 +245,7 @@ def find_manifest_entry(archive_dir, file_name=None):
222
245
  with open(manifest_path) as f:
223
246
  entries = [json.loads(line) for line in f if line.strip()]
224
247
  if file_name:
225
- entries = [e for e in entries if e['name'] == file_name]
248
+ entries = [e for e in entries if _entry_matches(e, file_name)]
226
249
  if not entries:
227
250
  sys.exit(f"no archived file found in {archive_dir}" + (f" matching {file_name}" if file_name else ""))
228
251
  return entries[-1] # last-write-wins, same convention ott itself uses
@@ -232,7 +255,9 @@ def load_manifest_entries(archive_dir, file_name=None):
232
255
  """Every distinct file in the archive, not just one — find_manifest_entry
233
256
  collapses to a single entries[-1], which is exactly why `host <dir>` with
234
257
  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).
258
+ 45-video archive. Dedupes by archive-relative path (last-write-wins,
259
+ same convention), so subdirectories are fine and a name can repeat
260
+ across them.
236
261
 
237
262
  Only 'video' and 'audio' entries are returned. Hosting depends on
238
263
  chunk data (load_leaves) and per-chunk byte math (entry['chunk_size']),
@@ -259,11 +284,14 @@ def load_manifest_entries(archive_dir, file_name=None):
259
284
  by_hash[e['sha256']] = e
260
285
  deduped = list(by_hash.values())
261
286
  if file_name:
262
- deduped = [e for e in deduped if e['name'] == file_name]
263
- by_name = {}
287
+ deduped = [e for e in deduped if _entry_matches(e, file_name)]
288
+ # then by where the file sits in the archive, not its bare name: two
289
+ # files called the same thing in different subdirectories are two
290
+ # files (Ryan: "can we support subdirectories?")
291
+ by_path = {}
264
292
  for e in deduped:
265
- by_name[e['name']] = e
266
- all_entries = list(by_name.values())
293
+ by_path[entry_rel_path(e)] = e
294
+ all_entries = list(by_path.values())
267
295
  entries = [e for e in all_entries if e.get('type') in ('video', 'audio')]
268
296
  if not entries:
269
297
  if all_entries:
@@ -300,7 +328,85 @@ def resolve_file_path(entry, archive_dir):
300
328
  last_path = entry.get('last_path')
301
329
  if last_path and os.path.exists(last_path):
302
330
  return last_path
303
- return os.path.join(archive_dir, entry['name'])
331
+ # the file's place inside the archive first -- a file archived from
332
+ # a subdirectory lives there, not at the archive root -- then the
333
+ # bare name at the root for entries that predate orig_path
334
+ rel = entry_rel_path(entry)
335
+ candidate = os.path.join(archive_dir, *rel.split('/'))
336
+ if os.path.exists(candidate):
337
+ return candidate
338
+ # A file moved into a subfolder and fixed up with `ott fix-renames`
339
+ # has a fresh last_path but its old orig_path (only `ott reindex`
340
+ # re-anchors that), and from inside a container that last_path is
341
+ # the host machine's. Its tail is still where the file sits under
342
+ # the archive, so try the trailing segments, longest first (Ryan:
343
+ # "I ran `ott fix-renames` but it still shows them all as skipped").
344
+ if last_path:
345
+ parts = last_path.replace('\\', '/').strip('/').split('/')
346
+ for k in range(len(parts) - 1, 0, -1):
347
+ tail = os.path.join(archive_dir, *parts[-k:])
348
+ if os.path.exists(tail):
349
+ return tail
350
+ if rel != entry['name'] and os.path.exists(os.path.join(archive_dir, entry['name'])):
351
+ return os.path.join(archive_dir, entry['name'])
352
+ # Last resort: the file was moved inside the archive and nothing in
353
+ # its manifest entry says where (fix-renames wasn't run, or ran
354
+ # against another copy). Find it by name anywhere under archive_dir,
355
+ # the same size when there's a choice (Ryan: "I have files in
356
+ # ./share/folder, but they're not showing up anymore").
357
+ found = _archive_file_index(archive_dir).get(entry['name']) or []
358
+ if found:
359
+ same_size = [p for p in found if entry.get('size') is None or _size_of(p) == entry.get('size')]
360
+ pick = same_size or found
361
+ if len(pick) == 1 or same_size:
362
+ return pick[0]
363
+ return candidate
364
+
365
+
366
+ def _size_of(path):
367
+ try:
368
+ return os.path.getsize(path)
369
+ except OSError:
370
+ return None
371
+
372
+
373
+ _archive_index_cache = {} # abspath(archive_dir) -> (built_at, {name: [paths]})
374
+ ARCHIVE_INDEX_TTL = 30
375
+
376
+
377
+ def _archive_file_index(archive_dir):
378
+ """Every file under archive_dir by bare name (the archive's own .ott/
379
+ and hidden directories skipped), rebuilt at most every
380
+ ARCHIVE_INDEX_TTL seconds, so resolving a whole archive of moved files
381
+ costs one walk rather than one per file."""
382
+ key = os.path.abspath(archive_dir)
383
+ hit = _archive_index_cache.get(key)
384
+ if hit and time.time() - hit[0] < ARCHIVE_INDEX_TTL:
385
+ return hit[1]
386
+ index = {}
387
+ for dirpath, dirs, files in os.walk(key):
388
+ dirs[:] = sorted(d for d in dirs if not d.startswith('.'))
389
+ for name in files:
390
+ index.setdefault(name, []).append(os.path.join(dirpath, name))
391
+ _archive_index_cache[key] = (time.time(), index)
392
+ return index
393
+
394
+
395
+ def entry_archive_rel(entry, archive_dir):
396
+ """Where the file actually sits under archive_dir, as a '/'-joined
397
+ relative path ('Live/Paris 1993/set.mkv') -- what gets announced as
398
+ the folder and listed on a host. From the resolved file when it's
399
+ inside archive_dir (so a moved file reports its new folder even while
400
+ its manifest entry still says the old one), else the entry's own."""
401
+ archive_dir = os.path.expanduser(archive_dir)
402
+ path = resolve_file_path(entry, archive_dir)
403
+ try:
404
+ rel = os.path.relpath(os.path.abspath(path), os.path.abspath(archive_dir))
405
+ except ValueError: # a different drive on Windows
406
+ return entry_rel_path(entry)
407
+ if rel.startswith('..'):
408
+ return entry_rel_path(entry)
409
+ return rel.replace(os.sep, '/')
304
410
 
305
411
 
306
412
  def _graceful_close(sock):
@@ -445,7 +551,8 @@ def _load_hostable_entries(archive_dir, file_name):
445
551
  if not os.path.exists(file_path):
446
552
  if file_name is not None or len(entries) == 1:
447
553
  sys.exit(f"archived file not found on disk at {file_path}")
448
- print(f"[host] skipping {entry.get('name')!r}: archived file not found on disk at {file_path}", file=sys.stderr)
554
+ print(f"[host] skipping {entry.get('name')!r}: archived file not found on disk at {file_path} "
555
+ f"(nor anywhere under {archive_dir} by that name)", file=sys.stderr)
449
556
  continue
450
557
  leaves = load_leaves(archive_dir, entry['sha256'])
451
558
  entries_by_hash[entry['sha256']] = (entry, leaves, file_path)
@@ -1589,7 +1696,7 @@ def sync_relays(relay_urls, identity=None, all_signers=False, per_signer_cap=Non
1589
1696
  return {'relays': report, 'unreachable': unreachable, 'events': len(keep)}
1590
1697
 
1591
1698
 
1592
- def publish(identity, relay_url, content_hash, title, host_addr, tunnel=None, ott_status=None):
1699
+ def publish(identity, relay_url, content_hash, title, host_addr, tunnel=None, ott_status=None, folder=None):
1593
1700
  """tunnel, if given, is 'relay_host:relay_port' for a tunnel_relay.py
1594
1701
  instance this host registered with — additive and backward compatible,
1595
1702
  same as the optional PRICE wire verb: an event without it just means
@@ -1609,6 +1716,11 @@ def publish(identity, relay_url, content_hash, title, host_addr, tunnel=None, ot
1609
1716
  tunnel=tunnels[0] if tunnels else None, ott_status=ott_status)
1610
1717
  if len(tunnels) > 1:
1611
1718
  fields['tunnels'] = tunnels
1719
+ # folder: where the file sits inside its archive ('Live/Paris 1993'),
1720
+ # so a listing can show the tree the host has; same additive rule --
1721
+ # a file at the archive root sends nothing new
1722
+ if folder:
1723
+ fields['folder'] = folder
1612
1724
  event = identity.sign_event('publish', **fields)
1613
1725
  return _post_to(relay_url, event)
1614
1726
 
@@ -1687,5 +1799,12 @@ def group_discover_by_content(results):
1687
1799
  rep = dict(group[0])
1688
1800
  rep['host_count'] = len(group)
1689
1801
  rep['hosts'] = [g['host'] for g in group]
1802
+ # the folder from whichever publisher names one (newest first):
1803
+ # a second host of the same file on an older build, or a mirror,
1804
+ # announces without it, and its newer event must not blank it
1805
+ for g in group:
1806
+ if g.get('folder'):
1807
+ rep['folder'] = g['folder']
1808
+ break
1690
1809
  merged.append(rep)
1691
1810
  return merged
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "weed-cli"
7
- version = "2.7.1"
7
+ version = "2.7.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" }
@@ -203,7 +203,8 @@ class WeedShell(cmd.Cmd):
203
203
  ott_status = node.ott_commit_status(archive_dir)
204
204
  for entry in entries:
205
205
  result = node.publish(self.identity, relay, entry['sha256'], entry['name'],
206
- f'{advertise_host}:{port}', tunnel=tunnel, ott_status=ott_status)
206
+ f'{advertise_host}:{port}', tunnel=tunnel, ott_status=ott_status,
207
+ folder=os.path.dirname(node.entry_archive_rel(entry, archive_dir)) or None)
207
208
  print(f' announced {entry["name"]} on {relay}: {result}')
208
209
  elif not relay:
209
210
  print(' no relay set (run `relay` first, or pass --relay) — hosting without announcing')
@@ -226,3 +226,34 @@ def test_publish_records_every_tunnel_in_failover_order(relay):
226
226
  tunnel='127.0.0.1:9199')
227
227
  single = next(e for e in node.discover([relay]) if e['content_hash'] == 'd' * 64)
228
228
  assert single['tunnel'] == '127.0.0.1:9199' and 'tunnels' not in single # unchanged shape
229
+
230
+
231
+ def test_publish_carries_the_hosts_folder_only_when_there_is_one(relay):
232
+ """A file hosted from a subdirectory of its archive announces that
233
+ folder, so a listing can show the host's tree; a file at the root
234
+ sends the same event it always did."""
235
+ identity = node.load_or_create_identity()
236
+ assert node.publish(identity, relay, content_hash='e' * 64, title='set.mkv', host_addr='127.0.0.1:9201',
237
+ folder='Live/Paris 1993').get('ok') is True
238
+ assert node.publish(identity, relay, content_hash='f' * 64, title='root.mkv', host_addr='127.0.0.1:9201').get('ok') is True
239
+ by_hash = {r['content_hash']: r for r in node.discover([relay])}
240
+ assert by_hash['e' * 64]['folder'] == 'Live/Paris 1993'
241
+ assert 'folder' not in by_hash['f' * 64]
242
+
243
+
244
+ def test_a_re_announce_replaces_the_signers_older_event_for_that_file(relay):
245
+ """Ryan: "Host shows 515 files in 18 folders, but I only see a subset
246
+ of those in Discover" -- the relay's per-signer cap counted every
247
+ announcement, and a host re-announces every file on every start, so
248
+ a big library churned its own oldest listings out. A newer
249
+ publish/unpublish for the same content by the same signer now
250
+ replaces the older one, so the relay holds one event per file."""
251
+ import time
252
+ identity = node.load_or_create_identity()
253
+ for i in range(3):
254
+ node.publish(identity, relay, content_hash='1' * 64, title='same file', host_addr='127.0.0.1:%d' % (9201 + i))
255
+ time.sleep(0.01)
256
+ node.publish(identity, relay, content_hash='2' * 64, title='another', host_addr='127.0.0.1:9201')
257
+ mine = [e for e in node.fetch_events(relay, 'publish') if e['payload']['signer_pubkey'] == identity.pubkey_hex()]
258
+ assert sorted(e['payload']['content_hash'][0] for e in mine) == ['1', '2'] # one event per file, not three
259
+ assert next(e for e in mine if e['payload']['content_hash'][0] == '1')['payload']['host'] == '127.0.0.1:9203' # the newest
@@ -59,7 +59,7 @@ def test_root_and_downloads_list_finished_tracks_newest_first(web_server):
59
59
  _seed()
60
60
  ui = FakeUI(web_server)
61
61
  plugin.Plugin(ui).run()
62
- assert [f[1] for f in ui.folders] == ['downloads', 'playlists', 'tags', 'party', 'live', 'live', 'live', 'autopilot']
62
+ assert [f[1] for f in ui.folders] == ['downloads', 'folders', 'playlists', 'tags', 'party', 'live', 'live', 'live', 'autopilot']
63
63
  ui = FakeUI(web_server)
64
64
  plugin.Plugin(ui).run(action='downloads')
65
65
  assert [m[0] for m in ui.items] == ['Track 2', 'Track 1', 'Track 0'] # newest first, extension dropped, no unfinished
@@ -187,7 +187,7 @@ def test_kodi_own_url_parameters_are_ignored(web_server):
187
187
  _seed()
188
188
  ui = FakeUI(web_server)
189
189
  plugin.Plugin(ui).run(content_type='video')
190
- assert [f[1] for f in ui.folders][:2] == ['downloads', 'playlists'] and not ui.notices
190
+ assert [f[1] for f in ui.folders][:2] == ['downloads', 'folders'] and not ui.notices
191
191
  ui = FakeUI(web_server)
192
192
  plugin.Plugin(ui).run(action='downloads', content_type='audio', tag='chill')
193
193
  assert [m[0] for m in ui.items] == ['Track 1', 'Track 0'] and not ui.notices
@@ -265,3 +265,44 @@ def test_muxed_live_feed_needs_audio_and_ffmpeg(web_server, monkeypatch):
265
265
  ui = FakeUI(web_server)
266
266
  plugin.Plugin(ui).run(action='live', what='mux')
267
267
  assert ui.played == [(web_server + '/api/orbit-mux', 'weed Orbit', {'title': 'weed Orbit', 'mediatype': 'video'})]
268
+
269
+
270
+ def test_folders_screen_walks_the_hosts_folder_tree(web_server):
271
+ """Ryan: "can the folder structure be visible in the web_ui and kodi
272
+ app?" A download remembers the folder its host announced; the Folders
273
+ screen lists subfolders (with counts, subfolders included) then the
274
+ files in the folder, and a track with no folder sits at the top."""
275
+ _seed()
276
+ with web_ui._lock:
277
+ web_ui._library['downloads']['a' * 64]['folder'] = 'Live/Paris 1993'
278
+ web_ui._library['downloads']['b' * 64]['folder'] = 'Live'
279
+ ui = FakeUI(web_server)
280
+ plugin.Plugin(ui).run(action='folders')
281
+ assert ui.folders == [('Live/ (2)', 'folders', {'path': 'Live'})]
282
+ assert [m[0] for m in ui.items] == ['Track 2'] # the one with no folder
283
+ ui = FakeUI(web_server)
284
+ plugin.Plugin(ui).run(action='folders', path='Live')
285
+ assert ui.folders == [('Paris 1993/ (1)', 'folders', {'path': 'Live/Paris 1993'})]
286
+ assert [m[0] for m in ui.items] == ['Track 1']
287
+ ui = FakeUI(web_server)
288
+ plugin.Plugin(ui).run(action='folders', path='Live/Paris 1993')
289
+ assert ui.folders == [] and [m[0] for m in ui.items] == ['Track 0']
290
+ assert ui.ended == ['musicvideos']
291
+
292
+
293
+ def test_folders_are_filled_in_for_downloads_made_before_folders_were_announced(web_server):
294
+ """Ryan: "when I open Folders in kodi, I just see files, no folders" --
295
+ the library's existing downloads carry no folder. The node fills one
296
+ in from what it's hosting itself (a file hosted and downloaded on the
297
+ same node), so the tree appears without re-downloading anything."""
298
+ _seed()
299
+ with web_ui._lock:
300
+ web_ui._hosts['h1'] = {'id': 'h1', 'status': 'running', 'name': 'x', 'files': [
301
+ {'name': 'Track 0.mp4', 'content_hash': 'a' * 64, 'path': 'Live/Paris 1993/Track 0.mp4'},
302
+ {'name': 'Track 1.mp4', 'content_hash': 'b' * 64, 'path': 'Track 1.mp4'}]}
303
+ ui = FakeUI(web_server)
304
+ plugin.Plugin(ui).run(action='folders')
305
+ assert ui.folders == [('Live/ (1)', 'folders', {'path': 'Live'})]
306
+ assert [m[0] for m in ui.items] == ['Track 2', 'Track 1']
307
+ with web_ui._lock:
308
+ assert web_ui._library['downloads']['a' * 64]['folder'] == 'Live/Paris 1993' # kept on the record
@@ -0,0 +1,201 @@
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 original bug this filter exists for: a non-chunked file
23
+ (originally an mp3, back when ott's extension-based is_video() typed
24
+ it 'image' for lack of any 'audio' type at all -- see
25
+ test_load_manifest_entries_includes_audio_alongside_video below for
26
+ that no longer being true) sitting in the same archive_dir used to
27
+ poison-pill hosting the whole directory -- host <dir> with no --file
28
+ should just skip a genuinely non-hostable entry (a real photo, here)
29
+ and host the real video."""
30
+ video = make_fake_archive(tmp_path, name='good.mp4')
31
+ make_fake_archive(tmp_path, name='photo.jpg', content_type='image')
32
+
33
+ entries = node.load_manifest_entries(str(tmp_path))
34
+ assert [e['sha256'] for e in entries] == [video['sha256']]
35
+
36
+
37
+ def test_load_manifest_entries_includes_audio_alongside_video(tmp_path):
38
+ """Real ask: support audio (mp3, etc.) in addition to video. ott's
39
+ own cmd_add now chunks audio the same way it always has video (see
40
+ its own is_audio()), so an audio-typed manifest entry has real chunk
41
+ data too -- this is the one place every hosting path (weed.py,
42
+ shell.py, web_ui.py) filters on 'is this actually hostable', and it
43
+ needs to say yes to both now, not just video."""
44
+ video = make_fake_archive(tmp_path, name='good.mp4')
45
+ audio = make_fake_archive(tmp_path, name='song.mp3', content_type='audio')
46
+
47
+ entries = node.load_manifest_entries(str(tmp_path))
48
+ assert {e['sha256'] for e in entries} == {video['sha256'], audio['sha256']}
49
+ # the audio entry got real chunk data, not the image-style single
50
+ # whole-file hash with no chunks
51
+ audio_entry = next(e for e in entries if e['sha256'] == audio['sha256'])
52
+ assert audio_entry['n_chunks'] > 1
53
+ assert node.load_leaves(str(tmp_path), audio['sha256']) is not None
54
+
55
+
56
+ def test_load_manifest_entries_explicit_non_video_file_errors_clearly(tmp_path):
57
+ make_fake_archive(tmp_path, name='good.mp4')
58
+ make_fake_archive(tmp_path, name='photo.jpg', content_type='image')
59
+
60
+ with pytest.raises(SystemExit, match='no hostable video/audio file found'):
61
+ node.load_manifest_entries(str(tmp_path), 'photo.jpg')
62
+
63
+
64
+ def test_load_manifest_entries_no_manifest_at_all(tmp_path):
65
+ with pytest.raises(SystemExit, match='no .ott/manifest.jsonl'):
66
+ node.load_manifest_entries(str(tmp_path))
67
+
68
+
69
+ def test_load_manifest_entries_dedupes_by_name_last_write_wins(tmp_path):
70
+ """Two manifest lines for the same file name (re-added after a real
71
+ edit) should collapse to the newer entry, not double-list it."""
72
+ os.makedirs(os.path.join(tmp_path, '.ott'), exist_ok=True)
73
+ manifest = os.path.join(tmp_path, '.ott', 'manifest.jsonl')
74
+ old = {'sha256': 'a' * 64, 'name': 'clip.mp4', 'orig_path': 'clip.mp4',
75
+ 'last_path': str(tmp_path / 'clip.mp4'), 'size': 1, 'added': '2020-01-01T00:00:00Z',
76
+ 'type': 'video', 'n_chunks': 1, 'chunk_size': 65536}
77
+ new = {**old, 'sha256': 'b' * 64, 'added': '2026-01-01T00:00:00Z'}
78
+ with open(manifest, 'w') as f:
79
+ f.write('%s\n%s\n' % (__import__('json').dumps(old), __import__('json').dumps(new)))
80
+
81
+ entries = node.load_manifest_entries(str(tmp_path))
82
+ assert len(entries) == 1
83
+ assert entries[0]['sha256'] == 'b' * 64
84
+
85
+
86
+ def test_load_leaves_round_trips_real_chunks(tmp_path):
87
+ entry = make_fake_archive(tmp_path, name='good.mp4', size=200_000, chunk_size=65_536)
88
+ leaves = node.load_leaves(str(tmp_path), entry['sha256'])
89
+ assert len(leaves) == entry['n_chunks']
90
+ assert len(leaves) > 1 # 200_000 bytes / 65_536 chunk_size genuinely spans multiple chunks
91
+
92
+
93
+ def test_load_leaves_missing_chunks_file_errors_clearly(tmp_path):
94
+ entry = make_fake_archive(tmp_path, name='good.mp4', video=False)
95
+ with pytest.raises(SystemExit, match='no chunks file at'):
96
+ node.load_leaves(str(tmp_path), entry['sha256'])
97
+
98
+
99
+ def test_resolve_file_path_prefers_last_path_when_it_exists(tmp_path):
100
+ entry = make_fake_archive(tmp_path, name='good.mp4')
101
+ resolved = node.resolve_file_path(entry, str(tmp_path))
102
+ assert resolved == entry['last_path']
103
+ assert os.path.exists(resolved)
104
+
105
+
106
+ def test_resolve_file_path_falls_back_when_last_path_is_stale(tmp_path):
107
+ """last_path is recorded at archive time on whatever machine ran
108
+ `ott add` -- trusting it unconditionally breaks the moment archive_dir
109
+ is the same content mounted somewhere else (see node.py's own
110
+ docstring for the real Docker-bind-mount incident this guards)."""
111
+ entry = make_fake_archive(tmp_path, name='good.mp4')
112
+ entry['last_path'] = '/nonexistent/path/on/a/different/machine/good.mp4'
113
+ resolved = node.resolve_file_path(entry, str(tmp_path))
114
+ assert resolved == os.path.join(str(tmp_path), 'good.mp4')
115
+
116
+
117
+ def test_a_file_in_a_subdirectory_resolves_there_when_last_path_is_stale(tmp_path):
118
+ """Ryan: "can we support subdirectories?" ott records orig_path relative
119
+ to the archive root, so a file added from a subdirectory is found
120
+ there when its absolute last_path is from another machine (the Docker
121
+ bind mount case) -- not looked for by bare name at the root."""
122
+ entry = make_fake_archive(tmp_path, name='Live/Paris 1993/set.mkv')
123
+ entry['last_path'] = '/somewhere/else/set.mkv'
124
+ assert node.resolve_file_path(entry, str(tmp_path)) == os.path.join(str(tmp_path), 'Live', 'Paris 1993', 'set.mkv')
125
+ kept, by_hash = node._load_hostable_entries(str(tmp_path), None)
126
+ assert [e['sha256'] for e in kept] == [entry['sha256']]
127
+
128
+
129
+ def test_the_same_name_in_two_subdirectories_is_two_files(tmp_path):
130
+ a = make_fake_archive(tmp_path, name='Studio/take.mp4')
131
+ b = make_fake_archive(tmp_path, name='Live/take.mp4')
132
+ entries = node.load_manifest_entries(str(tmp_path))
133
+ assert sorted(e['sha256'] for e in entries) == sorted([a['sha256'], b['sha256']])
134
+ assert all(e['name'] == 'take.mp4' for e in entries) # the bare name is what gets announced
135
+ # --file by the archive-relative path picks one; by bare name, both
136
+ assert [e['sha256'] for e in node.load_manifest_entries(str(tmp_path), 'Live/take.mp4')] == [b['sha256']]
137
+ assert len(node.load_manifest_entries(str(tmp_path), 'take.mp4')) == 2
138
+ assert node.find_manifest_entry(str(tmp_path), 'Studio/take.mp4')['sha256'] == a['sha256']
139
+
140
+
141
+ def test_entry_rel_path_never_climbs_out_of_the_archive():
142
+ assert node.entry_rel_path({'name': 'x.mp4', 'orig_path': '../../etc/x.mp4'}) == 'x.mp4'
143
+ assert node.entry_rel_path({'name': 'x.mp4', 'orig_path': '/abs/x.mp4'}) == 'x.mp4'
144
+ assert node.entry_rel_path({'name': 'x.mp4', 'orig_path': 'Sub\\x.mp4'}) == 'Sub/x.mp4'
145
+ assert node.entry_rel_path({'name': 'x.mp4'}) == 'x.mp4'
146
+
147
+
148
+ def test_a_file_moved_into_a_folder_and_fixed_with_ott_fix_renames_is_found_by_its_paths_tail(tmp_path):
149
+ """Ryan: "my old files which I moved into a folder ... I ran `ott
150
+ fix-renames` but it still shows them all as getting skipped".
151
+ fix-renames rewrites last_path (absolute, on the host machine) and
152
+ leaves orig_path as the old bare name; inside the container that
153
+ last_path doesn't exist. The node now tries the trailing segments of
154
+ last_path under the archive, so the file is found in its new folder,
155
+ hosted, and announced with that folder."""
156
+ entry = make_fake_archive(tmp_path, name='set.mkv')
157
+ os.makedirs(tmp_path / 'Live' / 'Paris 1993')
158
+ os.rename(tmp_path / 'set.mkv', tmp_path / 'Live' / 'Paris 1993' / 'set.mkv')
159
+ entry['last_path'] = '/home/ryan/share/Live/Paris 1993/set.mkv' # what fix-renames wrote, on the host
160
+ assert entry['orig_path'] == 'set.mkv' # and what it left alone
161
+ assert node.resolve_file_path(entry, str(tmp_path)) == os.path.join(str(tmp_path), 'Live', 'Paris 1993', 'set.mkv')
162
+ assert node.entry_archive_rel(entry, str(tmp_path)) == 'Live/Paris 1993/set.mkv'
163
+ with open(os.path.join(str(tmp_path), '.ott', 'manifest.jsonl'), 'w') as f:
164
+ f.write(__import__('json').dumps(entry) + '\n')
165
+ kept, by_hash = node._load_hostable_entries(str(tmp_path), None)
166
+ assert [e['sha256'] for e in kept] == [entry['sha256']]
167
+
168
+
169
+ def test_a_file_moved_into_a_folder_with_an_untouched_manifest_is_found_by_name_and_size(tmp_path):
170
+ """Ryan: "I have files in ./share/folder, but they're not showing up
171
+ anymore". Nothing on the entry says where the file went (last_path
172
+ still the old root location, orig_path the bare name), so the node
173
+ looks for it by name anywhere under the archive, taking the one of
174
+ the right size when the name repeats."""
175
+ entry = make_fake_archive(tmp_path, name='set.mkv', size=120_000)
176
+ os.makedirs(tmp_path / 'Mid-Air Thief' / 'Crumbling')
177
+ os.rename(tmp_path / 'set.mkv', tmp_path / 'Mid-Air Thief' / 'Crumbling' / 'set.mkv')
178
+ os.makedirs(tmp_path / 'Other')
179
+ with open(tmp_path / 'Other' / 'set.mkv', 'wb') as f: # a different file with the same name
180
+ f.write(b'x' * 10)
181
+ assert not os.path.exists(entry['last_path'])
182
+ node._archive_index_cache.clear()
183
+ assert node.resolve_file_path(entry, str(tmp_path)) == os.path.join(str(tmp_path), 'Mid-Air Thief', 'Crumbling', 'set.mkv')
184
+ assert node.entry_archive_rel(entry, str(tmp_path)) == 'Mid-Air Thief/Crumbling/set.mkv'
185
+ kept, by_hash = node._load_hostable_entries(str(tmp_path), None)
186
+ assert [e['sha256'] for e in kept] == [entry['sha256']]
187
+
188
+
189
+ def test_discover_grouping_keeps_a_folder_any_publisher_named():
190
+ """Ryan: "the folder still doesn't show up in Discover. It does show
191
+ up in Downloads though". One row per content hash keeps the newest
192
+ publisher's fields; if that publisher (an older build, a mirror)
193
+ announced without a folder, the folder comes from one that did."""
194
+ rows = [
195
+ {'content_hash': 'a' * 64, 'signer_pubkey': 'old', 'host': '1.2.3.4:9201', 'title': 'set.mkv', 'ts': 200},
196
+ {'content_hash': 'a' * 64, 'signer_pubkey': 'new', 'host': '5.6.7.8:9201', 'title': 'set.mkv', 'ts': 100, 'folder': 'Live'},
197
+ {'content_hash': 'b' * 64, 'signer_pubkey': 'old', 'host': '1.2.3.4:9201', 'title': 'root.mkv', 'ts': 300},
198
+ ]
199
+ merged = {r['content_hash']: r for r in node.group_discover_by_content(rows)}
200
+ assert merged['a' * 64]['host'] == '1.2.3.4:9201' and merged['a' * 64]['folder'] == 'Live'
201
+ assert 'folder' not in merged['b' * 64]
@@ -734,3 +734,36 @@ def test_tags_are_set_whole_normalised_and_kept_on_the_record(web_server):
734
734
  # a garbage payload clears rather than errors
735
735
  status, resp = http_post_json(f'{web_server}/api/tags', {'content_hash': h, 'tags': 'nope'})
736
736
  assert status == 200 and resp['tags'] == []
737
+
738
+
739
+ def test_upload_keeps_a_dropped_folders_subdirectory(web_server, tmp_path):
740
+ """A folder dropped on the Host tab arrives file by file as
741
+ 'Folder/Sub/clip.mp4' and lands in that subdirectory of archive_dir,
742
+ archived with orig_path relative to the archive root, so host finds it
743
+ there; '..' can't escape the archive."""
744
+ archive_dir = str(tmp_path / 'archive')
745
+ data = os.urandom(150_000)
746
+ status, resp = http_post_raw(_upload_url(web_server, 'Live/Paris 1993/set.mp4', archive_dir), data)
747
+ assert status == 200 and resp['ok'] is True
748
+ assert resp['name'] == 'set.mp4' and resp['path'] == 'Live/Paris 1993/set.mp4'
749
+ dest = os.path.join(archive_dir, 'Live', 'Paris 1993', 'set.mp4')
750
+ assert os.path.isfile(dest) and os.path.getsize(dest) == len(data)
751
+ entries = node.load_manifest_entries(archive_dir)
752
+ assert [(e['name'], e['orig_path']) for e in entries] == [('set.mp4', 'Live/Paris 1993/set.mp4')]
753
+ entries[0]['last_path'] = '/gone'
754
+ assert node.resolve_file_path(entries[0], archive_dir) == dest
755
+
756
+ status, resp = http_post_raw(_upload_url(web_server, 'Live/../../escape.mp4', archive_dir), b'x' * 10)
757
+ assert status == 200 and resp['path'] == 'Live/escape.mp4' # '..' segments dropped
758
+ assert not os.path.exists(os.path.join(str(tmp_path), 'escape.mp4'))
759
+
760
+
761
+ def test_a_download_keeps_the_folder_its_listing_came_with(web_server):
762
+ """The Discover row's folder rides along on the download request and
763
+ is kept on the job (and, once it finishes, the library record), which
764
+ is what the Downloads tab's folder filter and Kodi's Folders read."""
765
+ status, resp = http_post_json(f'{web_server}/api/download',
766
+ {'content_hash': 'a' * 64, 'relay': ['http://127.0.0.1:1'], 'title': 'x', 'folder': '/Live/Paris 1993/'})
767
+ assert status == 200
768
+ job = http_get_json(f'{web_server}/api/download/' + resp['job_id'])
769
+ assert job['folder'] == 'Live/Paris 1993'
@@ -109,6 +109,7 @@ def make_fake_archive(archive_dir, name='clip.mp4', size=200_000, chunk_size=65_
109
109
  ott_dir = os.path.join(archive_dir, '.ott')
110
110
  os.makedirs(os.path.join(ott_dir, 'chunks'), exist_ok=True)
111
111
  file_path = os.path.join(archive_dir, name)
112
+ os.makedirs(os.path.dirname(file_path), exist_ok=True) # name may carry a subdirectory
112
113
  with open(file_path, 'wb') as f:
113
114
  f.write(os.urandom(size))
114
115
 
@@ -120,7 +121,7 @@ def make_fake_archive(archive_dir, name='clip.mp4', size=200_000, chunk_size=65_
120
121
  chunks = chunk_hashes(file_path, chunk_size)
121
122
  digest = merkle_root(chunks)
122
123
  entry = {
123
- 'sha256': digest, 'name': name, 'orig_path': name, 'last_path': file_path,
124
+ 'sha256': digest, 'name': os.path.basename(name), 'orig_path': name, 'last_path': file_path,
124
125
  'size': size, 'added': '2026-01-01T00:00:00Z', 'type': content_type,
125
126
  'n_chunks': len(chunks), 'chunk_size': chunk_size,
126
127
  }
@@ -129,7 +130,7 @@ def make_fake_archive(archive_dir, name='clip.mp4', size=200_000, chunk_size=65_
129
130
  else:
130
131
  digest = hashlib.sha256(open(file_path, 'rb').read()).hexdigest()
131
132
  entry = {
132
- 'sha256': digest, 'name': name, 'orig_path': name, 'last_path': file_path,
133
+ 'sha256': digest, 'name': os.path.basename(name), 'orig_path': name, 'last_path': file_path,
133
134
  'size': size, 'added': '2026-01-01T00:00:00Z', 'type': content_type,
134
135
  'n_chunks': 1, 'chunk_size': None,
135
136
  }
@@ -889,6 +889,55 @@ def _load_library():
889
889
  pass
890
890
 
891
891
 
892
+ _folder_fetch = {'at': 0.0}
893
+
894
+
895
+ def _backfill_folders(mapping):
896
+ """Give downloads that have no folder yet the one their content is
897
+ known to sit in -- from this node's own hosts (a file hosted and
898
+ downloaded on the same node) or a relay listing. Downloads made before
899
+ folders were announced have none on their record (Ryan: "when I open
900
+ Folders in kodi, I just see files, no folders"), and so would every
901
+ download of a listing announced by an older node."""
902
+ changed = False
903
+ with _lock:
904
+ for h, rec in _library['downloads'].items():
905
+ if not rec.get('folder') and mapping.get(h):
906
+ rec['folder'] = mapping[h]
907
+ changed = True
908
+ if changed:
909
+ _save_library()
910
+ return changed
911
+
912
+
913
+ def _hosted_folders():
914
+ out = {}
915
+ with _lock:
916
+ for h in _hosts.values():
917
+ for f in h.get('files') or []:
918
+ folder = os.path.dirname(f.get('path') or '')
919
+ if folder:
920
+ out[f['content_hash']] = folder
921
+ return out
922
+
923
+
924
+ def _fill_folders_for_library():
925
+ """Before /api/library answers: the cheap local backfill every time,
926
+ and -- when something still has no folder -- a relay lookup in the
927
+ background at most every few minutes, so the next request has it."""
928
+ _backfill_folders(_hosted_folders())
929
+ with _lock:
930
+ missing = any(not rec.get('folder') and rec.get('job_id') for rec in _library['downloads'].values())
931
+ if missing and time.time() - _folder_fetch['at'] > 300:
932
+ _folder_fetch['at'] = time.time()
933
+ def fetch():
934
+ try:
935
+ _backfill_folders({r['content_hash']: r.get('folder') for r in node.discover([DEFAULT_RELAY])})
936
+ except Exception:
937
+ pass
938
+ threading.Thread(target=fetch, daemon=True).start()
939
+
940
+
892
941
  def _save_library():
893
942
  """Caller must hold _lock. Written to a tmp file + os.replace so a
894
943
  crash mid-write can't leave a half-written, unparseable JSON file
@@ -1006,7 +1055,8 @@ def _run_host_job(host_id, archive_dir, file_name, port, price, relay_urls, adve
1006
1055
  for relay_url in relay_urls:
1007
1056
  host_addr = f'{advertise_host}:{port}'
1008
1057
  result = node.publish(identity, relay_url, entry['sha256'], entry['name'], host_addr,
1009
- tunnel=tunnel, ott_status=ott_status)
1058
+ tunnel=tunnel, ott_status=ott_status,
1059
+ folder=os.path.dirname(node.entry_archive_rel(entry, archive_dir)) or None)
1010
1060
  # publish()/post_event() report a failed announce as a
1011
1061
  # normal {'ok': False, ...} return, not an exception (an
1012
1062
  # unreachable or malformed relay is routine, not
@@ -1021,7 +1071,7 @@ def _run_host_job(host_id, archive_dir, file_name, port, price, relay_urls, adve
1021
1071
  if isinstance(result, dict) and not result.get('ok', True):
1022
1072
  print(f' ✗ announce to {relay_url} failed: {result.get("error")}')
1023
1073
  announced = relay_urls
1024
- files = [{'name': e['name'], 'content_hash': e['sha256']} for e in entries]
1074
+ files = [{'name': e['name'], 'content_hash': e['sha256'], 'path': node.entry_archive_rel(e, archive_dir)} for e in entries]
1025
1075
  with _lock:
1026
1076
  _hosts[host_id].update(files=files, name=files[0]['name'],
1027
1077
  content_hash=files[0]['content_hash'],
@@ -1088,7 +1138,7 @@ def _run_download_job(job_id, content_hash, relay_urls, out_path, k, use_lightni
1088
1138
  _library['downloads'][content_hash] = {
1089
1139
  'content_hash': content_hash, 'job_id': job_id, 'path': path,
1090
1140
  'title': title, 'downloaded_at': time.time(), 'size': size, 'bps': bps,
1091
- 'signer_pubkey': signer_pubkey,
1141
+ 'signer_pubkey': signer_pubkey, 'folder': _jobs[job_id].get('folder'),
1092
1142
  **{k: prev[k] for k in ('tags', 'play_count', 'last_played') if k in prev},
1093
1143
  }
1094
1144
  _save_library()
@@ -1279,6 +1329,7 @@ class Handler(BaseHTTPRequestHandler):
1279
1329
  return self._handle_chat_get(qs)
1280
1330
  if path == '/api/discover':
1281
1331
  results = node.group_discover_by_content(node.discover(qs.get('relay') or [DEFAULT_RELAY]))
1332
+ _backfill_folders({r['content_hash']: r.get('folder') for r in results})
1282
1333
  return self._json({'results': results})
1283
1334
  if path == '/api/hosts':
1284
1335
  with _lock:
@@ -1296,6 +1347,7 @@ class Handler(BaseHTTPRequestHandler):
1296
1347
  h['ott_status'] = _ott_status(h['archive_dir'])
1297
1348
  return self._json({'hosts': hosts})
1298
1349
  if path == '/api/library':
1350
+ _fill_folders_for_library()
1299
1351
  with _lock:
1300
1352
  return self._json({
1301
1353
  'downloads': list(_library['downloads'].values()),
@@ -1461,11 +1513,16 @@ class Handler(BaseHTTPRequestHandler):
1461
1513
  if not raw_name:
1462
1514
  return self._json({'error': 'name query param required'}, status=400)
1463
1515
 
1464
- # basename only -- '..' or an absolute path in the filename
1465
- # can't escape archive_dir this way
1466
- safe_name = os.path.basename(raw_name)
1467
- if not safe_name or safe_name in ('.', '..'):
1516
+ # a dropped folder arrives as 'Folder/Sub/clip.mp4' and lands in
1517
+ # that subdirectory of archive_dir. '..' and empty segments are
1518
+ # dropped (an absolute path loses its leading slash the same
1519
+ # way), so nothing can climb out of archive_dir -- same effect as
1520
+ # the basename-only rule this replaces, for any file's own name
1521
+ parts = [seg for seg in raw_name.replace('\\', '/').split('/') if seg not in ('', '.', '..')]
1522
+ if not parts:
1468
1523
  return self._json({'error': f'invalid file name: {raw_name!r}'}, status=400)
1524
+ safe_name = parts[-1]
1525
+ rel_path = '/'.join(parts)
1469
1526
 
1470
1527
  if is_video(safe_name):
1471
1528
  content_type = 'video'
@@ -1478,8 +1535,8 @@ class Handler(BaseHTTPRequestHandler):
1478
1535
  status=400)
1479
1536
 
1480
1537
  archive_dir = os.path.expanduser(archive_dir)
1481
- os.makedirs(archive_dir, exist_ok=True)
1482
- dest_path = os.path.join(archive_dir, safe_name)
1538
+ dest_path = os.path.join(archive_dir, *parts)
1539
+ os.makedirs(os.path.dirname(dest_path), exist_ok=True)
1483
1540
 
1484
1541
  length = int(self.headers.get('Content-Length', 0))
1485
1542
  if length <= 0:
@@ -1519,7 +1576,7 @@ class Handler(BaseHTTPRequestHandler):
1519
1576
  chunks = chunk_hashes(dest_path, chunk_size)
1520
1577
  digest = merkle_root(chunks) if chunks else hashlib.sha256(b'').hexdigest()
1521
1578
  entry = {
1522
- 'sha256': digest, 'name': safe_name, 'orig_path': safe_name, 'last_path': dest_path,
1579
+ 'sha256': digest, 'name': safe_name, 'orig_path': rel_path, 'last_path': dest_path,
1523
1580
  'size': written, 'added': time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime()),
1524
1581
  'type': content_type, 'n_chunks': len(chunks), 'chunk_size': chunk_size,
1525
1582
  }
@@ -1564,7 +1621,7 @@ class Handler(BaseHTTPRequestHandler):
1564
1621
  f.write(existing + json.dumps(entry) + '\n')
1565
1622
  os.replace(manifest_tmp, manifest_path)
1566
1623
 
1567
- self._json({'ok': True, 'name': safe_name, 'content_hash': digest, 'type': content_type,
1624
+ self._json({'ok': True, 'name': safe_name, 'path': rel_path, 'content_hash': digest, 'type': content_type,
1568
1625
  'size': written, 'n_chunks': len(chunks), 'archive_dir': archive_dir})
1569
1626
 
1570
1627
  def _handle_host(self, body):
@@ -1652,12 +1709,13 @@ class Handler(BaseHTTPRequestHandler):
1652
1709
  return self._json({'error': "lightning requires lightning_node (who's paying)"}, status=400)
1653
1710
  title = body.get('title')
1654
1711
  signer_pubkey = body.get('signer_pubkey')
1712
+ folder = (body.get('folder') or '').strip('/') or None # the host's folder, from the listing
1655
1713
 
1656
1714
  job_id = uuid.uuid4().hex[:12]
1657
1715
  with _lock:
1658
1716
  _jobs[job_id] = {'status': 'running', 'idx': 0, 'n_chunks': None,
1659
1717
  'content_hash': content_hash, 'path': None, 'title': title,
1660
- 'signer_pubkey': signer_pubkey, 'error': None}
1718
+ 'signer_pubkey': signer_pubkey, 'folder': folder, 'error': None}
1661
1719
  threading.Thread(target=_run_download_job,
1662
1720
  args=(job_id, content_hash, relay_urls, out_path, k, use_lightning, title,
1663
1721
  signer_pubkey),
@@ -256,7 +256,8 @@ def cmd_host(args):
256
256
  host_addr = f'{args.advertise_host}:{args.port}'
257
257
  for entry in entries:
258
258
  result = node.publish(identity, relay_url, entry['sha256'], entry['name'], host_addr,
259
- tunnel=args.tunnel, ott_status=ott_status)
259
+ tunnel=args.tunnel, ott_status=ott_status,
260
+ folder=os.path.dirname(node.entry_archive_rel(entry, archive_dir)) or None)
260
261
  print(f"announced {entry['name']} on {relay_url}: {result}")
261
262
  # REGISTER's token is the file's own content hash (see
262
263
  # run_host_tunnel/connect_via_tunnel), so a whole tree just means one
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: weed-cli
3
- Version: 2.7.1
3
+ Version: 2.7.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
@@ -385,9 +385,26 @@ docker compose -f docker-compose.node.yml exec node python3 weed.py discover #
385
385
  Python changes (`web_ui.py`, `node.py`) still need `make node`.
386
386
  - An entry's `last_path` (recorded at `ott add` time, on whatever
387
387
  machine ran it) is only trusted if it exists on disk; otherwise the
388
- node falls back to the given archive directory. This matters the
389
- moment the same content is mounted somewhere else than where it was
390
- archived — e.g. `/share` here vs. wherever it originally lived.
388
+ node looks in the given archive directory at the entry's own
389
+ archive-relative path. This matters the moment the same content is
390
+ mounted somewhere else than where it was archived — e.g. `/share`
391
+ here vs. wherever it originally lived.
392
+ - Subdirectories are fine: `ott add` walks a folder, records where
393
+ each file sits relative to the archive root, and `host` serves the
394
+ whole tree from one `.ott/`. Two files with the same name in
395
+ different subdirectories are two files; `--file Live/set.mkv` picks
396
+ one by its relative path. A folder dropped on the web UI's Host tab
397
+ is uploaded file by file into the same subdirectories.
398
+ Files moved into a folder after archiving are found too: `ott
399
+ fix-renames` (or `ott reindex`) records the new place, and the node
400
+ looks for a file under the archive by the tail of that path, so the
401
+ host machine's absolute path not existing inside a container doesn't
402
+ matter.
403
+ The folder travels with the listing (a `folder` field on the publish
404
+ event), so Discover shows it as a crumb ahead of the title with a
405
+ folder filter, a download keeps it for the Downloads tab's folder
406
+ chips and sort, the Active hosts table counts files and folders, and
407
+ the Kodi add-on has a **Folders** screen that walks the tree.
391
408
 
392
409
  ## Core mechanisms
393
410
 
@@ -1,114 +0,0 @@
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 original bug this filter exists for: a non-chunked file
23
- (originally an mp3, back when ott's extension-based is_video() typed
24
- it 'image' for lack of any 'audio' type at all -- see
25
- test_load_manifest_entries_includes_audio_alongside_video below for
26
- that no longer being true) sitting in the same archive_dir used to
27
- poison-pill hosting the whole directory -- host <dir> with no --file
28
- should just skip a genuinely non-hostable entry (a real photo, here)
29
- and host the real video."""
30
- video = make_fake_archive(tmp_path, name='good.mp4')
31
- make_fake_archive(tmp_path, name='photo.jpg', content_type='image')
32
-
33
- entries = node.load_manifest_entries(str(tmp_path))
34
- assert [e['sha256'] for e in entries] == [video['sha256']]
35
-
36
-
37
- def test_load_manifest_entries_includes_audio_alongside_video(tmp_path):
38
- """Real ask: support audio (mp3, etc.) in addition to video. ott's
39
- own cmd_add now chunks audio the same way it always has video (see
40
- its own is_audio()), so an audio-typed manifest entry has real chunk
41
- data too -- this is the one place every hosting path (weed.py,
42
- shell.py, web_ui.py) filters on 'is this actually hostable', and it
43
- needs to say yes to both now, not just video."""
44
- video = make_fake_archive(tmp_path, name='good.mp4')
45
- audio = make_fake_archive(tmp_path, name='song.mp3', content_type='audio')
46
-
47
- entries = node.load_manifest_entries(str(tmp_path))
48
- assert {e['sha256'] for e in entries} == {video['sha256'], audio['sha256']}
49
- # the audio entry got real chunk data, not the image-style single
50
- # whole-file hash with no chunks
51
- audio_entry = next(e for e in entries if e['sha256'] == audio['sha256'])
52
- assert audio_entry['n_chunks'] > 1
53
- assert node.load_leaves(str(tmp_path), audio['sha256']) is not None
54
-
55
-
56
- def test_load_manifest_entries_explicit_non_video_file_errors_clearly(tmp_path):
57
- make_fake_archive(tmp_path, name='good.mp4')
58
- make_fake_archive(tmp_path, name='photo.jpg', content_type='image')
59
-
60
- with pytest.raises(SystemExit, match='no hostable video/audio file found'):
61
- node.load_manifest_entries(str(tmp_path), 'photo.jpg')
62
-
63
-
64
- def test_load_manifest_entries_no_manifest_at_all(tmp_path):
65
- with pytest.raises(SystemExit, match='no .ott/manifest.jsonl'):
66
- node.load_manifest_entries(str(tmp_path))
67
-
68
-
69
- def test_load_manifest_entries_dedupes_by_name_last_write_wins(tmp_path):
70
- """Two manifest lines for the same file name (re-added after a real
71
- edit) should collapse to the newer entry, not double-list it."""
72
- os.makedirs(os.path.join(tmp_path, '.ott'), exist_ok=True)
73
- manifest = os.path.join(tmp_path, '.ott', 'manifest.jsonl')
74
- old = {'sha256': 'a' * 64, 'name': 'clip.mp4', 'orig_path': 'clip.mp4',
75
- 'last_path': str(tmp_path / 'clip.mp4'), 'size': 1, 'added': '2020-01-01T00:00:00Z',
76
- 'type': 'video', 'n_chunks': 1, 'chunk_size': 65536}
77
- new = {**old, 'sha256': 'b' * 64, 'added': '2026-01-01T00:00:00Z'}
78
- with open(manifest, 'w') as f:
79
- f.write('%s\n%s\n' % (__import__('json').dumps(old), __import__('json').dumps(new)))
80
-
81
- entries = node.load_manifest_entries(str(tmp_path))
82
- assert len(entries) == 1
83
- assert entries[0]['sha256'] == 'b' * 64
84
-
85
-
86
- def test_load_leaves_round_trips_real_chunks(tmp_path):
87
- entry = make_fake_archive(tmp_path, name='good.mp4', size=200_000, chunk_size=65_536)
88
- leaves = node.load_leaves(str(tmp_path), entry['sha256'])
89
- assert len(leaves) == entry['n_chunks']
90
- assert len(leaves) > 1 # 200_000 bytes / 65_536 chunk_size genuinely spans multiple chunks
91
-
92
-
93
- def test_load_leaves_missing_chunks_file_errors_clearly(tmp_path):
94
- entry = make_fake_archive(tmp_path, name='good.mp4', video=False)
95
- with pytest.raises(SystemExit, match='no chunks file at'):
96
- node.load_leaves(str(tmp_path), entry['sha256'])
97
-
98
-
99
- def test_resolve_file_path_prefers_last_path_when_it_exists(tmp_path):
100
- entry = make_fake_archive(tmp_path, name='good.mp4')
101
- resolved = node.resolve_file_path(entry, str(tmp_path))
102
- assert resolved == entry['last_path']
103
- assert os.path.exists(resolved)
104
-
105
-
106
- def test_resolve_file_path_falls_back_when_last_path_is_stale(tmp_path):
107
- """last_path is recorded at archive time on whatever machine ran
108
- `ott add` -- trusting it unconditionally breaks the moment archive_dir
109
- is the same content mounted somewhere else (see node.py's own
110
- docstring for the real Docker-bind-mount incident this guards)."""
111
- entry = make_fake_archive(tmp_path, name='good.mp4')
112
- entry['last_path'] = '/nonexistent/path/on/a/different/machine/good.mp4'
113
- resolved = node.resolve_file_path(entry, str(tmp_path))
114
- assert resolved == os.path.join(str(tmp_path), 'good.mp4')
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes