weed-cli 2.7.0__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.
- {weed_cli-2.7.0 → weed_cli-2.7.2}/PKG-INFO +30 -6
- {weed_cli-2.7.0 → weed_cli-2.7.2}/README.md +29 -5
- {weed_cli-2.7.0 → weed_cli-2.7.2}/discovery_relay.py +30 -2
- {weed_cli-2.7.0 → weed_cli-2.7.2}/node.py +128 -9
- {weed_cli-2.7.0 → weed_cli-2.7.2}/pyproject.toml +1 -1
- {weed_cli-2.7.0 → weed_cli-2.7.2}/shell.py +2 -1
- {weed_cli-2.7.0 → weed_cli-2.7.2}/tests/test_discovery_relay.py +31 -0
- {weed_cli-2.7.0 → weed_cli-2.7.2}/tests/test_kodi_addon.py +43 -2
- weed_cli-2.7.2/tests/test_node_manifest.py +201 -0
- {weed_cli-2.7.0 → weed_cli-2.7.2}/tests/test_web_ui_api.py +33 -0
- {weed_cli-2.7.0 → weed_cli-2.7.2}/tests/testutil.py +3 -2
- {weed_cli-2.7.0 → weed_cli-2.7.2}/web_ui.py +70 -12
- {weed_cli-2.7.0 → weed_cli-2.7.2}/weed.py +2 -1
- {weed_cli-2.7.0 → weed_cli-2.7.2}/weed_cli.egg-info/PKG-INFO +30 -6
- weed_cli-2.7.0/tests/test_node_manifest.py +0 -114
- {weed_cli-2.7.0 → weed_cli-2.7.2}/LICENSE +0 -0
- {weed_cli-2.7.0 → weed_cli-2.7.2}/dht.py +0 -0
- {weed_cli-2.7.0 → weed_cli-2.7.2}/lightning_settle.py +0 -0
- {weed_cli-2.7.0 → weed_cli-2.7.2}/poc_reputation.py +0 -0
- {weed_cli-2.7.0 → weed_cli-2.7.2}/setup.cfg +0 -0
- {weed_cli-2.7.0 → weed_cli-2.7.2}/tests/test_dht.py +0 -0
- {weed_cli-2.7.0 → weed_cli-2.7.2}/tests/test_host_live_reload.py +0 -0
- {weed_cli-2.7.0 → weed_cli-2.7.2}/tests/test_orbit_audio.py +0 -0
- {weed_cli-2.7.0 → weed_cli-2.7.2}/tests/test_orbit_ws.py +0 -0
- {weed_cli-2.7.0 → weed_cli-2.7.2}/tests/test_timing_challenge.py +0 -0
- {weed_cli-2.7.0 → weed_cli-2.7.2}/tests/test_tunnel_failover.py +0 -0
- {weed_cli-2.7.0 → weed_cli-2.7.2}/tunnel_relay.py +0 -0
- {weed_cli-2.7.0 → weed_cli-2.7.2}/weed_cli.egg-info/SOURCES.txt +0 -0
- {weed_cli-2.7.0 → weed_cli-2.7.2}/weed_cli.egg-info/dependency_links.txt +0 -0
- {weed_cli-2.7.0 → weed_cli-2.7.2}/weed_cli.egg-info/entry_points.txt +0 -0
- {weed_cli-2.7.0 → weed_cli-2.7.2}/weed_cli.egg-info/requires.txt +0 -0
- {weed_cli-2.7.0 → 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.
|
|
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
|
|
@@ -285,6 +285,13 @@ Zoom retune every mode; every setting persists in the browser.
|
|
|
285
285
|
tagged" pick in the pool row limits the draw to one tag. The ☑
|
|
286
286
|
button beside the toggle unfolds a checkbox per mode: untick the ones
|
|
287
287
|
Autopilot should leave alone (they stay a click or a pad away).
|
|
288
|
+
- **Crossfader** (⇆ in the transport bar, or the MIDI panel's Crossfader
|
|
289
|
+
row): cues the next track -- the queue's next, else Autopilot's pick --
|
|
290
|
+
on a second hidden deck and fades to it, equal-power on the sound and
|
|
291
|
+
opacity on the picture; the far end of the fader makes it the track
|
|
292
|
+
with no gap. The fader stays where it landed and the next fade runs
|
|
293
|
+
the other way, as on a DJ mixer, so a knob left at an end never
|
|
294
|
+
commits by accident. A track ending mid-cue jumps to the cued one.
|
|
288
295
|
- **Video swap** (⇄ on the player): another download's picture stands
|
|
289
296
|
in for the current track's — for an mp3 or a static-image video, so
|
|
290
297
|
the visuals have real footage. Audio stays with the track; the
|
|
@@ -335,8 +342,8 @@ mode selector; a registered transition gets a Fade option and a turn in
|
|
|
335
342
|
taking the draw loop down. `web/orbit_extras.js` (loaded by default) is
|
|
336
343
|
built entirely on that API and adds Halftone, Lava, Terrain, Rain,
|
|
337
344
|
Lissajous, Vinyl 33, Cube, VHS, Win95, Joy Division, Spectrogram, Stained
|
|
338
|
-
glass, Fireworks, Screensaver, Slit-scan, Skyline, Globe, Starfield
|
|
339
|
-
Spectrum 3D, plus the Melt, Dissolve, Iris, Shatter, Wave, Spin, Zoom
|
|
345
|
+
glass, Fireworks, Screensaver, Slit-scan, Skyline, Globe, Starfield,
|
|
346
|
+
Spectrum 3D, Brain and Arcade, plus the Melt, Dissolve, Iris, Shatter, Wave, Spin, Zoom
|
|
340
347
|
blur, RGB split, VHS, Win95, Blinds, Flip tiles, CRT off, Droplet, Blur,
|
|
341
348
|
Slide, Flash, Desktop cube, Carousel and Doors transitions (the 3D ones
|
|
342
349
|
draw perspective faces as runs of affine strips, since Canvas 2D has no
|
|
@@ -378,9 +385,26 @@ docker compose -f docker-compose.node.yml exec node python3 weed.py discover #
|
|
|
378
385
|
Python changes (`web_ui.py`, `node.py`) still need `make node`.
|
|
379
386
|
- An entry's `last_path` (recorded at `ott add` time, on whatever
|
|
380
387
|
machine ran it) is only trusted if it exists on disk; otherwise the
|
|
381
|
-
node
|
|
382
|
-
moment the same content is
|
|
383
|
-
archived — e.g. `/share`
|
|
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.
|
|
384
408
|
|
|
385
409
|
## Core mechanisms
|
|
386
410
|
|
|
@@ -253,6 +253,13 @@ Zoom retune every mode; every setting persists in the browser.
|
|
|
253
253
|
tagged" pick in the pool row limits the draw to one tag. The ☑
|
|
254
254
|
button beside the toggle unfolds a checkbox per mode: untick the ones
|
|
255
255
|
Autopilot should leave alone (they stay a click or a pad away).
|
|
256
|
+
- **Crossfader** (⇆ in the transport bar, or the MIDI panel's Crossfader
|
|
257
|
+
row): cues the next track -- the queue's next, else Autopilot's pick --
|
|
258
|
+
on a second hidden deck and fades to it, equal-power on the sound and
|
|
259
|
+
opacity on the picture; the far end of the fader makes it the track
|
|
260
|
+
with no gap. The fader stays where it landed and the next fade runs
|
|
261
|
+
the other way, as on a DJ mixer, so a knob left at an end never
|
|
262
|
+
commits by accident. A track ending mid-cue jumps to the cued one.
|
|
256
263
|
- **Video swap** (⇄ on the player): another download's picture stands
|
|
257
264
|
in for the current track's — for an mp3 or a static-image video, so
|
|
258
265
|
the visuals have real footage. Audio stays with the track; the
|
|
@@ -303,8 +310,8 @@ mode selector; a registered transition gets a Fade option and a turn in
|
|
|
303
310
|
taking the draw loop down. `web/orbit_extras.js` (loaded by default) is
|
|
304
311
|
built entirely on that API and adds Halftone, Lava, Terrain, Rain,
|
|
305
312
|
Lissajous, Vinyl 33, Cube, VHS, Win95, Joy Division, Spectrogram, Stained
|
|
306
|
-
glass, Fireworks, Screensaver, Slit-scan, Skyline, Globe, Starfield
|
|
307
|
-
Spectrum 3D, plus the Melt, Dissolve, Iris, Shatter, Wave, Spin, Zoom
|
|
313
|
+
glass, Fireworks, Screensaver, Slit-scan, Skyline, Globe, Starfield,
|
|
314
|
+
Spectrum 3D, Brain and Arcade, plus the Melt, Dissolve, Iris, Shatter, Wave, Spin, Zoom
|
|
308
315
|
blur, RGB split, VHS, Win95, Blinds, Flip tiles, CRT off, Droplet, Blur,
|
|
309
316
|
Slide, Flash, Desktop cube, Carousel and Doors transitions (the 3D ones
|
|
310
317
|
draw perspective faces as runs of affine strips, since Canvas 2D has no
|
|
@@ -346,9 +353,26 @@ docker compose -f docker-compose.node.yml exec node python3 weed.py discover #
|
|
|
346
353
|
Python changes (`web_ui.py`, `node.py`) still need `make node`.
|
|
347
354
|
- An entry's `last_path` (recorded at `ott add` time, on whatever
|
|
348
355
|
machine ran it) is only trusted if it exists on disk; otherwise the
|
|
349
|
-
node
|
|
350
|
-
moment the same content is
|
|
351
|
-
archived — e.g. `/share`
|
|
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.
|
|
352
376
|
|
|
353
377
|
## Core mechanisms
|
|
354
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
|
-
|
|
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
|
|
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
|
|
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
|
|
263
|
-
|
|
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
|
-
|
|
266
|
-
all_entries = list(
|
|
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
|
-
|
|
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}"
|
|
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.
|
|
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', '
|
|
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
|
-
#
|
|
1465
|
-
#
|
|
1466
|
-
|
|
1467
|
-
|
|
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.
|
|
1482
|
-
|
|
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':
|
|
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.
|
|
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
|
|
@@ -285,6 +285,13 @@ Zoom retune every mode; every setting persists in the browser.
|
|
|
285
285
|
tagged" pick in the pool row limits the draw to one tag. The ☑
|
|
286
286
|
button beside the toggle unfolds a checkbox per mode: untick the ones
|
|
287
287
|
Autopilot should leave alone (they stay a click or a pad away).
|
|
288
|
+
- **Crossfader** (⇆ in the transport bar, or the MIDI panel's Crossfader
|
|
289
|
+
row): cues the next track -- the queue's next, else Autopilot's pick --
|
|
290
|
+
on a second hidden deck and fades to it, equal-power on the sound and
|
|
291
|
+
opacity on the picture; the far end of the fader makes it the track
|
|
292
|
+
with no gap. The fader stays where it landed and the next fade runs
|
|
293
|
+
the other way, as on a DJ mixer, so a knob left at an end never
|
|
294
|
+
commits by accident. A track ending mid-cue jumps to the cued one.
|
|
288
295
|
- **Video swap** (⇄ on the player): another download's picture stands
|
|
289
296
|
in for the current track's — for an mp3 or a static-image video, so
|
|
290
297
|
the visuals have real footage. Audio stays with the track; the
|
|
@@ -335,8 +342,8 @@ mode selector; a registered transition gets a Fade option and a turn in
|
|
|
335
342
|
taking the draw loop down. `web/orbit_extras.js` (loaded by default) is
|
|
336
343
|
built entirely on that API and adds Halftone, Lava, Terrain, Rain,
|
|
337
344
|
Lissajous, Vinyl 33, Cube, VHS, Win95, Joy Division, Spectrogram, Stained
|
|
338
|
-
glass, Fireworks, Screensaver, Slit-scan, Skyline, Globe, Starfield
|
|
339
|
-
Spectrum 3D, plus the Melt, Dissolve, Iris, Shatter, Wave, Spin, Zoom
|
|
345
|
+
glass, Fireworks, Screensaver, Slit-scan, Skyline, Globe, Starfield,
|
|
346
|
+
Spectrum 3D, Brain and Arcade, plus the Melt, Dissolve, Iris, Shatter, Wave, Spin, Zoom
|
|
340
347
|
blur, RGB split, VHS, Win95, Blinds, Flip tiles, CRT off, Droplet, Blur,
|
|
341
348
|
Slide, Flash, Desktop cube, Carousel and Doors transitions (the 3D ones
|
|
342
349
|
draw perspective faces as runs of affine strips, since Canvas 2D has no
|
|
@@ -378,9 +385,26 @@ docker compose -f docker-compose.node.yml exec node python3 weed.py discover #
|
|
|
378
385
|
Python changes (`web_ui.py`, `node.py`) still need `make node`.
|
|
379
386
|
- An entry's `last_path` (recorded at `ott add` time, on whatever
|
|
380
387
|
machine ran it) is only trusted if it exists on disk; otherwise the
|
|
381
|
-
node
|
|
382
|
-
moment the same content is
|
|
383
|
-
archived — e.g. `/share`
|
|
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.
|
|
384
408
|
|
|
385
409
|
## Core mechanisms
|
|
386
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
|
|
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
|