weed-cli 2.5.4__tar.gz → 2.6.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {weed_cli-2.5.4 → weed_cli-2.6.0}/PKG-INFO +44 -10
- {weed_cli-2.5.4 → weed_cli-2.6.0}/README.md +43 -9
- {weed_cli-2.5.4 → weed_cli-2.6.0}/pyproject.toml +1 -1
- weed_cli-2.6.0/tests/test_orbit_audio.py +471 -0
- {weed_cli-2.5.4 → weed_cli-2.6.0}/tests/test_web_ui_api.py +27 -0
- {weed_cli-2.5.4 → weed_cli-2.6.0}/web_ui.py +524 -20
- {weed_cli-2.5.4 → weed_cli-2.6.0}/weed_cli.egg-info/PKG-INFO +44 -10
- {weed_cli-2.5.4 → weed_cli-2.6.0}/weed_cli.egg-info/SOURCES.txt +1 -0
- {weed_cli-2.5.4 → weed_cli-2.6.0}/LICENSE +0 -0
- {weed_cli-2.5.4 → weed_cli-2.6.0}/dht.py +0 -0
- {weed_cli-2.5.4 → weed_cli-2.6.0}/discovery_relay.py +0 -0
- {weed_cli-2.5.4 → weed_cli-2.6.0}/lightning_settle.py +0 -0
- {weed_cli-2.5.4 → weed_cli-2.6.0}/node.py +0 -0
- {weed_cli-2.5.4 → weed_cli-2.6.0}/poc_reputation.py +0 -0
- {weed_cli-2.5.4 → weed_cli-2.6.0}/setup.cfg +0 -0
- {weed_cli-2.5.4 → weed_cli-2.6.0}/shell.py +0 -0
- {weed_cli-2.5.4 → weed_cli-2.6.0}/tests/test_dht.py +0 -0
- {weed_cli-2.5.4 → weed_cli-2.6.0}/tests/test_discovery_relay.py +0 -0
- {weed_cli-2.5.4 → weed_cli-2.6.0}/tests/test_host_live_reload.py +0 -0
- {weed_cli-2.5.4 → weed_cli-2.6.0}/tests/test_node_manifest.py +0 -0
- {weed_cli-2.5.4 → weed_cli-2.6.0}/tests/test_orbit_ws.py +0 -0
- {weed_cli-2.5.4 → weed_cli-2.6.0}/tests/test_timing_challenge.py +0 -0
- {weed_cli-2.5.4 → weed_cli-2.6.0}/tests/test_tunnel_failover.py +0 -0
- {weed_cli-2.5.4 → weed_cli-2.6.0}/tests/testutil.py +0 -0
- {weed_cli-2.5.4 → weed_cli-2.6.0}/tunnel_relay.py +0 -0
- {weed_cli-2.5.4 → weed_cli-2.6.0}/weed.py +0 -0
- {weed_cli-2.5.4 → weed_cli-2.6.0}/weed_cli.egg-info/dependency_links.txt +0 -0
- {weed_cli-2.5.4 → weed_cli-2.6.0}/weed_cli.egg-info/entry_points.txt +0 -0
- {weed_cli-2.5.4 → weed_cli-2.6.0}/weed_cli.egg-info/requires.txt +0 -0
- {weed_cli-2.5.4 → weed_cli-2.6.0}/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.
|
|
3
|
+
Version: 2.6.0
|
|
4
4
|
Summary: Censorship-resistant video PoC — discovery/hosting/download over signed relay events, a real Kademlia DHT, or a TLS-capable NAT-traversal tunnel
|
|
5
5
|
License: MIT
|
|
6
6
|
Keywords: p2p,video,censorship-resistant,discovery,dht,kademlia,nat-traversal
|
|
@@ -175,6 +175,12 @@ then carry it, so scanning is the login; the page shows an unlock
|
|
|
175
175
|
prompt if opened any other way; the stream URLs it hands to VLC carry
|
|
176
176
|
it as `?token=`, since a player can't send a cookie.
|
|
177
177
|
|
|
178
|
+
Downloads can be **tagged**: a finished row in the Downloads tab has
|
|
179
|
+
tag chips and a "+ tag" field (Enter adds, commas add several, × removes),
|
|
180
|
+
the bar above the table filters by tag, tags are kept on the library
|
|
181
|
+
record on the server (`POST /api/tags` replaces a record's list), and
|
|
182
|
+
Autopilot can draw its next track from one tag.
|
|
183
|
+
|
|
178
184
|
```bash
|
|
179
185
|
weed serve # alias for `web`, positional: serve [bind] [port]
|
|
180
186
|
weed serve 0.0.0.0 8080 # reachable from your phone; prints a scan-to-open QR
|
|
@@ -230,22 +236,49 @@ Zoom retune every mode; every setting persists in the browser.
|
|
|
230
236
|
crash runs at 3×).
|
|
231
237
|
- **Navigation**, Blender-style: scroll zooms, left-drag pans,
|
|
232
238
|
middle-drag rotates, shift+middle pans, ctrl+middle zooms,
|
|
233
|
-
double-click resets. `f` fullscreen
|
|
239
|
+
double-click resets. `f` fullscreen (the whole dialog: the header's
|
|
240
|
+
controls and the rows float over a full-bleed canvas and fade out
|
|
241
|
+
after a few seconds without the mouse), ←/→ cycle modes (fullscreen),
|
|
234
242
|
shift+1-9,0 jump to a mode, ↑/↓ ASCII brightness, `[`/`]` resolution.
|
|
235
243
|
- **Network stream** (📡): the canvas goes out as MJPEG on
|
|
236
244
|
`/api/orbit-view` for VLC (`vlc --demux=mjpeg --network-caching=300
|
|
237
245
|
<url>`; 🔗 copies the URL, token included), a Roku IP-camera viewer,
|
|
238
246
|
or the guest party page. Resolution and JPEG quality are selectable;
|
|
239
247
|
the ⏱ slider delays the local audio by up to 10 s to line up with a
|
|
240
|
-
laggy viewer.
|
|
241
|
-
|
|
242
|
-
|
|
248
|
+
laggy viewer. While it runs the canvas is held at the stream's 16:9
|
|
249
|
+
(letterboxed on screen if the window isn't), so the frame is filled
|
|
250
|
+
edge to edge and never distorted whatever shape the window is. The
|
|
251
|
+
stream keeps running while the visualizer is closed, minimised to PIP,
|
|
252
|
+
or in a background tab. Picture only by default — the 🔊 toggle beside
|
|
253
|
+
📡 sends the player's audio too, as a live Opus stream (WebM, or Ogg in
|
|
254
|
+
Firefox) on `/api/orbit-audio`. Late joiners get a clean start. The
|
|
255
|
+
guest party page shows a 🔊 listen button; VLC takes both with
|
|
256
|
+
`vlc --demux=mjpeg <view-url> --input-slave=<audio-url>`; OBS takes the
|
|
257
|
+
audio URL as a second Media Source. Safari/iOS can't play WebM audio.
|
|
243
258
|
- **MIDI** (🎹): an AKAI MPK mini or any Web MIDI controller
|
|
244
259
|
(Chrome/Edge/Firefox). Pads pick modes and fire actions, knobs turn
|
|
245
260
|
the sliders or sweep through modes/transitions/character sets;
|
|
246
|
-
endless encoders are detected on their own.
|
|
261
|
+
endless encoders are detected on their own. Knobs have a detent at a
|
|
262
|
+
parameter's home value (rotation straight, zoom and speed 1x), so the
|
|
263
|
+
middle of a pot or an encoder click that crosses it lands exactly
|
|
264
|
+
there; its width is the panel's **detent** field, 0 to turn it off.
|
|
265
|
+
Each selector row has a **clicks** field, how many encoder clicks move
|
|
266
|
+
one entry (mode and fade style default to 1, ASCII chars to 3 so a
|
|
267
|
+
nudge doesn't run through several sets). Every row, plugin modes
|
|
247
268
|
and transitions included, has a Learn button; **export** saves the
|
|
248
269
|
keymap as a `.json` file and **import** loads one.
|
|
270
|
+
- **Autopilot** (🤖 in the visualizer's header, or a MIDI pad): the
|
|
271
|
+
visualizer drives itself. It listens for onsets and the running energy,
|
|
272
|
+
holds a mode for a while, drops back to the plain video, then picks a
|
|
273
|
+
fresh mode -- energetic ones when the music is up, calmer ones when it
|
|
274
|
+
isn't -- with a punchy or gentle transition to match the hit, and when
|
|
275
|
+
a track ends with nothing queued it draws the next download by
|
|
276
|
+
lottery. The control cycles through three states: off, **↓** (the
|
|
277
|
+
lottery favours the least-played and longest-rested tracks) and **↑**
|
|
278
|
+
(it favours the most-played, the crowd-pleasers), and the "tracks
|
|
279
|
+
tagged" pick in the pool row limits the draw to one tag. The ☑
|
|
280
|
+
button beside the toggle unfolds a checkbox per mode: untick the ones
|
|
281
|
+
Autopilot should leave alone (they stay a click or a pad away).
|
|
249
282
|
- **Video swap** (⇄ on the player): another download's picture stands
|
|
250
283
|
in for the current track's — for an mp3 or a static-image video, so
|
|
251
284
|
the visuals have real footage. Audio stays with the track; the
|
|
@@ -533,7 +566,8 @@ mechanisms hold up:
|
|
|
533
566
|
and votes are anonymous per browser cookie — enough to put a
|
|
534
567
|
LAN-bound UI behind something and hand guests a safe subset, not a
|
|
535
568
|
reason to face it at the internet.
|
|
536
|
-
- The Orbit stream is
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
can't take it directly
|
|
569
|
+
- The Orbit stream is two separate feeds, MJPEG picture and (optionally)
|
|
570
|
+
Opus audio, not one muxed A/V stream: fine for VLC with `--input-slave`,
|
|
571
|
+
OBS, an IP-camera app or the party page, but there's no HLS/MP4
|
|
572
|
+
endpoint, so a stock Roku or smart-TV player can't take it directly,
|
|
573
|
+
and Safari/iOS can't play the WebM audio feed.
|
|
@@ -143,6 +143,12 @@ then carry it, so scanning is the login; the page shows an unlock
|
|
|
143
143
|
prompt if opened any other way; the stream URLs it hands to VLC carry
|
|
144
144
|
it as `?token=`, since a player can't send a cookie.
|
|
145
145
|
|
|
146
|
+
Downloads can be **tagged**: a finished row in the Downloads tab has
|
|
147
|
+
tag chips and a "+ tag" field (Enter adds, commas add several, × removes),
|
|
148
|
+
the bar above the table filters by tag, tags are kept on the library
|
|
149
|
+
record on the server (`POST /api/tags` replaces a record's list), and
|
|
150
|
+
Autopilot can draw its next track from one tag.
|
|
151
|
+
|
|
146
152
|
```bash
|
|
147
153
|
weed serve # alias for `web`, positional: serve [bind] [port]
|
|
148
154
|
weed serve 0.0.0.0 8080 # reachable from your phone; prints a scan-to-open QR
|
|
@@ -198,22 +204,49 @@ Zoom retune every mode; every setting persists in the browser.
|
|
|
198
204
|
crash runs at 3×).
|
|
199
205
|
- **Navigation**, Blender-style: scroll zooms, left-drag pans,
|
|
200
206
|
middle-drag rotates, shift+middle pans, ctrl+middle zooms,
|
|
201
|
-
double-click resets. `f` fullscreen
|
|
207
|
+
double-click resets. `f` fullscreen (the whole dialog: the header's
|
|
208
|
+
controls and the rows float over a full-bleed canvas and fade out
|
|
209
|
+
after a few seconds without the mouse), ←/→ cycle modes (fullscreen),
|
|
202
210
|
shift+1-9,0 jump to a mode, ↑/↓ ASCII brightness, `[`/`]` resolution.
|
|
203
211
|
- **Network stream** (📡): the canvas goes out as MJPEG on
|
|
204
212
|
`/api/orbit-view` for VLC (`vlc --demux=mjpeg --network-caching=300
|
|
205
213
|
<url>`; 🔗 copies the URL, token included), a Roku IP-camera viewer,
|
|
206
214
|
or the guest party page. Resolution and JPEG quality are selectable;
|
|
207
215
|
the ⏱ slider delays the local audio by up to 10 s to line up with a
|
|
208
|
-
laggy viewer.
|
|
209
|
-
|
|
210
|
-
|
|
216
|
+
laggy viewer. While it runs the canvas is held at the stream's 16:9
|
|
217
|
+
(letterboxed on screen if the window isn't), so the frame is filled
|
|
218
|
+
edge to edge and never distorted whatever shape the window is. The
|
|
219
|
+
stream keeps running while the visualizer is closed, minimised to PIP,
|
|
220
|
+
or in a background tab. Picture only by default — the 🔊 toggle beside
|
|
221
|
+
📡 sends the player's audio too, as a live Opus stream (WebM, or Ogg in
|
|
222
|
+
Firefox) on `/api/orbit-audio`. Late joiners get a clean start. The
|
|
223
|
+
guest party page shows a 🔊 listen button; VLC takes both with
|
|
224
|
+
`vlc --demux=mjpeg <view-url> --input-slave=<audio-url>`; OBS takes the
|
|
225
|
+
audio URL as a second Media Source. Safari/iOS can't play WebM audio.
|
|
211
226
|
- **MIDI** (🎹): an AKAI MPK mini or any Web MIDI controller
|
|
212
227
|
(Chrome/Edge/Firefox). Pads pick modes and fire actions, knobs turn
|
|
213
228
|
the sliders or sweep through modes/transitions/character sets;
|
|
214
|
-
endless encoders are detected on their own.
|
|
229
|
+
endless encoders are detected on their own. Knobs have a detent at a
|
|
230
|
+
parameter's home value (rotation straight, zoom and speed 1x), so the
|
|
231
|
+
middle of a pot or an encoder click that crosses it lands exactly
|
|
232
|
+
there; its width is the panel's **detent** field, 0 to turn it off.
|
|
233
|
+
Each selector row has a **clicks** field, how many encoder clicks move
|
|
234
|
+
one entry (mode and fade style default to 1, ASCII chars to 3 so a
|
|
235
|
+
nudge doesn't run through several sets). Every row, plugin modes
|
|
215
236
|
and transitions included, has a Learn button; **export** saves the
|
|
216
237
|
keymap as a `.json` file and **import** loads one.
|
|
238
|
+
- **Autopilot** (🤖 in the visualizer's header, or a MIDI pad): the
|
|
239
|
+
visualizer drives itself. It listens for onsets and the running energy,
|
|
240
|
+
holds a mode for a while, drops back to the plain video, then picks a
|
|
241
|
+
fresh mode -- energetic ones when the music is up, calmer ones when it
|
|
242
|
+
isn't -- with a punchy or gentle transition to match the hit, and when
|
|
243
|
+
a track ends with nothing queued it draws the next download by
|
|
244
|
+
lottery. The control cycles through three states: off, **↓** (the
|
|
245
|
+
lottery favours the least-played and longest-rested tracks) and **↑**
|
|
246
|
+
(it favours the most-played, the crowd-pleasers), and the "tracks
|
|
247
|
+
tagged" pick in the pool row limits the draw to one tag. The ☑
|
|
248
|
+
button beside the toggle unfolds a checkbox per mode: untick the ones
|
|
249
|
+
Autopilot should leave alone (they stay a click or a pad away).
|
|
217
250
|
- **Video swap** (⇄ on the player): another download's picture stands
|
|
218
251
|
in for the current track's — for an mp3 or a static-image video, so
|
|
219
252
|
the visuals have real footage. Audio stays with the track; the
|
|
@@ -501,7 +534,8 @@ mechanisms hold up:
|
|
|
501
534
|
and votes are anonymous per browser cookie — enough to put a
|
|
502
535
|
LAN-bound UI behind something and hand guests a safe subset, not a
|
|
503
536
|
reason to face it at the internet.
|
|
504
|
-
- The Orbit stream is
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
can't take it directly
|
|
537
|
+
- The Orbit stream is two separate feeds, MJPEG picture and (optionally)
|
|
538
|
+
Opus audio, not one muxed A/V stream: fine for VLC with `--input-slave`,
|
|
539
|
+
OBS, an IP-camera app or the party page, but there's no HLS/MP4
|
|
540
|
+
endpoint, so a stock Roku or smart-TV player can't take it directly,
|
|
541
|
+
and Safari/iOS can't play the WebM audio feed.
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "weed-cli"
|
|
7
|
-
version = "2.
|
|
7
|
+
version = "2.6.0"
|
|
8
8
|
description = "Censorship-resistant video PoC — discovery/hosting/download over signed relay events, a real Kademlia DHT, or a TLS-capable NAT-traversal tunnel"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = { text = "MIT" }
|
|
@@ -0,0 +1,471 @@
|
|
|
1
|
+
"""
|
|
2
|
+
The orbit audio relay (web_ui.py _LiveContainerRelay and the two
|
|
3
|
+
endpoints around it). The relay is tested as a pure function over
|
|
4
|
+
synthetic WebM/Ogg byte strings -- the late-joiner cut is the whole
|
|
5
|
+
point of it, and it's exactly the kind of thing a real recording would
|
|
6
|
+
only ever exercise by luck. The end-to-end tests push blobs through a
|
|
7
|
+
hand-rolled WebSocket client (see test_orbit_ws) into a real WebUIServer
|
|
8
|
+
and read them back off /api/orbit-audio with a raw socket, same as the
|
|
9
|
+
MJPEG tests do. Stdlib only, like the server.
|
|
10
|
+
"""
|
|
11
|
+
import os
|
|
12
|
+
import queue
|
|
13
|
+
import socket
|
|
14
|
+
import threading
|
|
15
|
+
import time
|
|
16
|
+
|
|
17
|
+
import pytest
|
|
18
|
+
|
|
19
|
+
import web_ui
|
|
20
|
+
from testutil import http_get_json
|
|
21
|
+
from test_orbit_ws import client_frame, ws_connect, _hostport, _read_until
|
|
22
|
+
|
|
23
|
+
EBML = web_ui._LiveContainerRelay.WEBM_EBML
|
|
24
|
+
CLUSTER = web_ui._LiveContainerRelay.WEBM_CLUSTER
|
|
25
|
+
OGGS = web_ui._LiveContainerRelay.OGG_PAGE
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@pytest.fixture(autouse=True)
|
|
29
|
+
def _reset_audio_state():
|
|
30
|
+
"""Module globals no other fixture resets -- a listener left behind
|
|
31
|
+
would otherwise still be fed by the next test."""
|
|
32
|
+
web_ui._orbit_audio_relay.end()
|
|
33
|
+
web_ui._orbit_audio.update(on=False, mime=None, since=0.0)
|
|
34
|
+
yield
|
|
35
|
+
web_ui._orbit_audio_relay.end()
|
|
36
|
+
web_ui._orbit_audio.update(on=False, mime=None, since=0.0)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
# ── synthetic containers ───────────────────────────────────────────────
|
|
40
|
+
def webm_init():
|
|
41
|
+
"""EBML header + Segment (unknown size) + Info + Tracks, shaped like
|
|
42
|
+
Chromium's MediaRecorder output, contents irrelevant to the relay."""
|
|
43
|
+
return (EBML + b'\x9f' + os.urandom(31) # EBML header, 31 bytes of body
|
|
44
|
+
+ b'\x18\x53\x80\x67\x01\xff\xff\xff\xff\xff\xff\xff' # Segment, unknown size
|
|
45
|
+
+ b'\x15\x49\xa9\x66\x8a' + os.urandom(10) # Info
|
|
46
|
+
+ b'\x16\x54\xae\x6b\x90' + os.urandom(16)) # Tracks
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
UNKNOWN = b'\x01\xff\xff\xff\xff\xff\xff\xff'
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def simple_block(n=40, payload=None):
|
|
53
|
+
"""SimpleBlock: A3, 1-byte size vint, n bytes of random 'Opus'."""
|
|
54
|
+
payload = os.urandom(n) if payload is None else payload
|
|
55
|
+
assert len(payload) < 127
|
|
56
|
+
return b'\xa3' + bytes([0x80 | len(payload)]) + payload
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def timecode(tc=0):
|
|
60
|
+
"""Timecode element: E7, size, minimal big-endian unsigned value."""
|
|
61
|
+
body = tc.to_bytes(max(1, (tc.bit_length() + 7) // 8), 'big')
|
|
62
|
+
return b'\xe7' + bytes([0x80 | len(body)]) + body
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def webm_cluster(n=40, tc=0, blocks=1):
|
|
66
|
+
"""A live Cluster: ID, unknown-size vint, Timecode element, then
|
|
67
|
+
`blocks` SimpleBlocks' worth of random 'Opus'."""
|
|
68
|
+
return CLUSTER + UNKNOWN + timecode(tc) + b''.join(simple_block(n) for _ in range(blocks))
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def parse_webm(data, init_len):
|
|
72
|
+
"""A tiny EBML walker for what a late reader was handed: checks the
|
|
73
|
+
init segment, then that the rest is a sequence of whole Clusters
|
|
74
|
+
(unknown size) each made of a Timecode and whole SimpleBlocks.
|
|
75
|
+
Returns [(timecode, [block payloads...]), ...]."""
|
|
76
|
+
pos = init_len
|
|
77
|
+
clusters = []
|
|
78
|
+
while pos < len(data):
|
|
79
|
+
assert data[pos:pos + 4] == CLUSTER, data[pos:pos + 4]
|
|
80
|
+
assert data[pos + 4:pos + 12] == UNKNOWN
|
|
81
|
+
pos += 12
|
|
82
|
+
assert data[pos] == 0xE7
|
|
83
|
+
tlen = data[pos + 1] & 0x7F
|
|
84
|
+
tc = int.from_bytes(data[pos + 2:pos + 2 + tlen], 'big')
|
|
85
|
+
pos += 2 + tlen
|
|
86
|
+
blocks = []
|
|
87
|
+
while pos < len(data) and data[pos] == 0xA3:
|
|
88
|
+
blen = data[pos + 1] & 0x7F
|
|
89
|
+
blocks.append(data[pos + 2:pos + 2 + blen])
|
|
90
|
+
assert len(blocks[-1]) == blen, 'truncated SimpleBlock'
|
|
91
|
+
pos += 2 + blen
|
|
92
|
+
clusters.append((tc, blocks))
|
|
93
|
+
return clusters
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def ogg_page(n=30):
|
|
97
|
+
return OGGS + b'\x00' + os.urandom(22) + os.urandom(n)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def drain(q):
|
|
101
|
+
out = []
|
|
102
|
+
while True:
|
|
103
|
+
try:
|
|
104
|
+
out.append(q.get_nowait())
|
|
105
|
+
except queue.Empty:
|
|
106
|
+
return out
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
# ── WebM ───────────────────────────────────────────────────────────────
|
|
110
|
+
def test_webm_early_subscriber_gets_every_byte_verbatim():
|
|
111
|
+
relay = web_ui._LiveContainerRelay()
|
|
112
|
+
relay.start('audio/webm;codecs=opus')
|
|
113
|
+
q = relay.subscribe()
|
|
114
|
+
blobs = [webm_init() + webm_cluster()[:20], webm_cluster()[20:] + webm_cluster(), webm_cluster()]
|
|
115
|
+
assert sum(relay.feed(b) for b in blobs) == 0
|
|
116
|
+
assert drain(q) == blobs
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def test_webm_init_segment_is_everything_before_the_first_cluster():
|
|
120
|
+
relay = web_ui._LiveContainerRelay()
|
|
121
|
+
relay.start('audio/webm')
|
|
122
|
+
init = webm_init()
|
|
123
|
+
assert relay.init_segment is None
|
|
124
|
+
relay.feed(init[:30]) # the header arrives in pieces
|
|
125
|
+
assert relay.init_segment is None
|
|
126
|
+
relay.feed(init[30:] + webm_cluster())
|
|
127
|
+
assert relay.init_segment == init
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def test_webm_late_subscriber_gets_init_then_starts_exactly_at_a_cluster():
|
|
131
|
+
"""Joining inside a one-block Cluster, with no further SimpleBlock
|
|
132
|
+
in it: the next clean start is the next Cluster, delivered whole
|
|
133
|
+
even though its header arrives split across two blobs. (This used
|
|
134
|
+
to re-feed the last 7 bytes of c2 before c3 -- a stream no muxer
|
|
135
|
+
produces -- which the element scanner rightly can't follow; the
|
|
136
|
+
stream is now consistent and the split lands inside c3's header.)"""
|
|
137
|
+
relay = web_ui._LiveContainerRelay()
|
|
138
|
+
relay.start('audio/webm;codecs=opus')
|
|
139
|
+
init = webm_init()
|
|
140
|
+
c1, c2, c3 = webm_cluster(), webm_cluster(), webm_cluster()
|
|
141
|
+
relay.feed(init + c1)
|
|
142
|
+
relay.feed(c2[:25]) # a blob that ends mid-SimpleBlock
|
|
143
|
+
q = relay.subscribe() # joins mid-cluster
|
|
144
|
+
# the tail of c2 has no element start in it: nothing yet but the init
|
|
145
|
+
relay.feed(c2[25:])
|
|
146
|
+
assert drain(q) == [init]
|
|
147
|
+
# c3's header is split: 7 bytes now, the rest next blob. Nothing can
|
|
148
|
+
# start on a half-parsed header; when it completes, the whole of c3
|
|
149
|
+
# (those 7 carried bytes included) is delivered.
|
|
150
|
+
relay.feed(c3[:7])
|
|
151
|
+
assert drain(q) == []
|
|
152
|
+
relay.feed(c3[7:])
|
|
153
|
+
assert drain(q) == [c3]
|
|
154
|
+
# live from here on
|
|
155
|
+
c4 = webm_cluster()
|
|
156
|
+
relay.feed(c4[:10]); relay.feed(c4[10:])
|
|
157
|
+
assert b''.join(drain(q)) == c4
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def test_webm_late_subscriber_start_is_playable_as_a_whole():
|
|
161
|
+
"""What a late reader's socket actually carries, end to end, is a
|
|
162
|
+
valid stream: init segment immediately followed by a Cluster ID
|
|
163
|
+
(here a synthesized one, since the join lands on a SimpleBlock)."""
|
|
164
|
+
relay = web_ui._LiveContainerRelay()
|
|
165
|
+
relay.start('audio/webm')
|
|
166
|
+
init = webm_init()
|
|
167
|
+
relay.feed(init + webm_cluster() + webm_cluster()[:15])
|
|
168
|
+
q = relay.subscribe()
|
|
169
|
+
relay.feed(webm_cluster()[15:] + webm_cluster())
|
|
170
|
+
got = b''.join(drain(q))
|
|
171
|
+
assert got.startswith(init + CLUSTER)
|
|
172
|
+
assert got[:4] == EBML
|
|
173
|
+
assert len(parse_webm(got, len(init))) == 2
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def test_webm_late_subscriber_starts_at_the_next_simpleblock_with_a_synthesized_cluster():
|
|
177
|
+
"""The fix for the 30-second wait: joining in the middle of a
|
|
178
|
+
SimpleBlock, the reader gets the init segment, a synthesized Cluster
|
|
179
|
+
header carrying the real Cluster's Timecode, then the stream from
|
|
180
|
+
the very next A3 -- and the whole thing parses as init + one Cluster
|
|
181
|
+
of whole SimpleBlocks."""
|
|
182
|
+
relay = web_ui._LiveContainerRelay()
|
|
183
|
+
relay.start('audio/webm;codecs=opus')
|
|
184
|
+
init = webm_init()
|
|
185
|
+
tc = 0x1234
|
|
186
|
+
b1, b2, b3, b4 = (simple_block() for _ in range(4))
|
|
187
|
+
relay.feed(init + CLUSTER + UNKNOWN + timecode(tc) + b1)
|
|
188
|
+
relay.feed(b2[:17]) # mid-SimpleBlock
|
|
189
|
+
q = relay.subscribe()
|
|
190
|
+
relay.feed(b2[17:] + b3 + b4[:5]) # b3 starts partway in
|
|
191
|
+
b5 = simple_block()
|
|
192
|
+
relay.feed(b4[5:] + b5)
|
|
193
|
+
got = drain(q)
|
|
194
|
+
assert got[0] == init
|
|
195
|
+
assert got[1] == CLUSTER + UNKNOWN + timecode(tc) # synthesized, same Timecode
|
|
196
|
+
assert got[2] == b3 + b4[:5] # from the next A3 exactly
|
|
197
|
+
assert got[3] == b4[5:] + b5 # live from there
|
|
198
|
+
clusters = parse_webm(b''.join(got), len(init))
|
|
199
|
+
assert clusters == [(tc, [b3[2:], b4[2:], b5[2:]])]
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def test_webm_late_subscriber_before_any_cluster_waits_for_the_first_cluster():
|
|
203
|
+
"""No Cluster Timecode known yet: there's nothing to synthesize
|
|
204
|
+
from, so a SimpleBlock isn't a clean start and the reader waits for
|
|
205
|
+
the next Cluster ID, as before. (The first Cluster here has no
|
|
206
|
+
Timecode element at all -- not something a muxer writes, but the
|
|
207
|
+
only way to be inside a Cluster without one.)"""
|
|
208
|
+
relay = web_ui._LiveContainerRelay()
|
|
209
|
+
relay.start('audio/webm')
|
|
210
|
+
init = webm_init()
|
|
211
|
+
relay.feed(init + CLUSTER + UNKNOWN) # (split here: the init detector wants E7 after the size)
|
|
212
|
+
relay.feed(simple_block())
|
|
213
|
+
assert relay.init_segment == init
|
|
214
|
+
q = relay.subscribe()
|
|
215
|
+
relay.feed(simple_block() + simple_block())
|
|
216
|
+
assert drain(q) == [init] # whole SimpleBlocks, but no Timecode to lend them
|
|
217
|
+
c2 = webm_cluster(tc=7, blocks=2)
|
|
218
|
+
relay.feed(c2)
|
|
219
|
+
assert drain(q) == [c2]
|
|
220
|
+
assert parse_webm(init + c2, len(init)) == [(7, [c2[-84:-42][2:], c2[-42:][2:]])]
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def test_webm_cluster_id_split_across_blobs_still_starts_the_reader_on_it():
|
|
224
|
+
"""The init segment ends the instant the first Cluster ID shows, so
|
|
225
|
+
that ID may be all the scanner has of the Cluster when a reader
|
|
226
|
+
joins. The carried header bytes are prepended when it completes:
|
|
227
|
+
the reader gets the whole first Cluster, not the second."""
|
|
228
|
+
relay = web_ui._LiveContainerRelay()
|
|
229
|
+
relay.start('audio/webm')
|
|
230
|
+
init = webm_init()
|
|
231
|
+
relay.feed(init + CLUSTER)
|
|
232
|
+
q = relay.subscribe()
|
|
233
|
+
c1 = webm_cluster(blocks=3)
|
|
234
|
+
relay.feed(c1[4:])
|
|
235
|
+
assert drain(q) == [init, c1]
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def test_webm_cluster_id_inside_opus_data_is_not_a_boundary():
|
|
239
|
+
"""A chance 1F43B675 in the compressed audio -- even one followed by
|
|
240
|
+
a plausible size vint and Timecode, which fooled the old byte
|
|
241
|
+
search -- is inside a SimpleBlock's payload as far as the element
|
|
242
|
+
scanner is concerned, so a late reader waits for the real next
|
|
243
|
+
element."""
|
|
244
|
+
relay = web_ui._LiveContainerRelay()
|
|
245
|
+
relay.start('audio/webm')
|
|
246
|
+
relay.feed(webm_init() + webm_cluster())
|
|
247
|
+
fake = CLUSTER + UNKNOWN + timecode(0)
|
|
248
|
+
blk = simple_block(payload=os.urandom(6) + fake + os.urandom(9))
|
|
249
|
+
relay.feed(blk[:4])
|
|
250
|
+
q = relay.subscribe()
|
|
251
|
+
relay.feed(blk[4:]) # the fake Cluster is in here
|
|
252
|
+
assert drain(q) == [relay.init_segment] # still waiting
|
|
253
|
+
real = simple_block()
|
|
254
|
+
relay.feed(real)
|
|
255
|
+
assert drain(q) == [relay._synth_cluster_header(0), real]
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
# ── Ogg ────────────────────────────────────────────────────────────────
|
|
259
|
+
def test_ogg_init_segment_is_the_first_blob_and_late_readers_start_on_a_page():
|
|
260
|
+
relay = web_ui._LiveContainerRelay()
|
|
261
|
+
relay.start('audio/ogg;codecs=opus')
|
|
262
|
+
first = ogg_page() + ogg_page() # the BOS/header pages
|
|
263
|
+
relay.feed(first)
|
|
264
|
+
assert relay.init_segment == first
|
|
265
|
+
p1, p2 = ogg_page(), ogg_page()
|
|
266
|
+
relay.feed(p1[:12])
|
|
267
|
+
q = relay.subscribe()
|
|
268
|
+
relay.feed(p1[12:] + p2) # p2 starts partway in
|
|
269
|
+
assert drain(q) == [first, p2]
|
|
270
|
+
p3 = ogg_page()
|
|
271
|
+
relay.feed(p3)
|
|
272
|
+
assert drain(q) == [p3]
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def test_ogg_early_subscriber_gets_everything():
|
|
276
|
+
relay = web_ui._LiveContainerRelay()
|
|
277
|
+
relay.start('audio/ogg')
|
|
278
|
+
q = relay.subscribe()
|
|
279
|
+
blobs = [ogg_page() + ogg_page(), ogg_page()[:9], ogg_page()[9:]]
|
|
280
|
+
for b in blobs:
|
|
281
|
+
relay.feed(b)
|
|
282
|
+
assert drain(q) == blobs
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def test_ogg_capture_pattern_needs_the_version_byte():
|
|
286
|
+
relay = web_ui._LiveContainerRelay()
|
|
287
|
+
relay.start('audio/ogg')
|
|
288
|
+
relay.feed(ogg_page())
|
|
289
|
+
q = relay.subscribe()
|
|
290
|
+
relay.feed(b'xx' + OGGS + b'\x07' + os.urandom(5)) # 'OggS' then version 7: not a page
|
|
291
|
+
assert drain(q) == [relay.init_segment]
|
|
292
|
+
page = ogg_page()
|
|
293
|
+
relay.feed(b'yy' + page)
|
|
294
|
+
assert drain(q) == [page]
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
# ── policy ─────────────────────────────────────────────────────────────
|
|
298
|
+
def test_full_queue_drops_oldest_and_keeps_the_reader():
|
|
299
|
+
relay = web_ui._LiveContainerRelay(maxsize=3)
|
|
300
|
+
relay.start('audio/webm')
|
|
301
|
+
q = relay.subscribe() # early: live from the start
|
|
302
|
+
blobs = [b'b%d' % i for i in range(6)]
|
|
303
|
+
dropped = sum(relay.feed(b) for b in blobs)
|
|
304
|
+
assert dropped == 3
|
|
305
|
+
assert relay.subscriber_count() == 1
|
|
306
|
+
assert drain(q) == blobs[-3:]
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def test_end_sends_none_and_a_new_start_forgets_the_old_init():
|
|
310
|
+
relay = web_ui._LiveContainerRelay()
|
|
311
|
+
relay.start('audio/webm')
|
|
312
|
+
relay.feed(webm_init() + webm_cluster())
|
|
313
|
+
q = relay.subscribe()
|
|
314
|
+
relay.end()
|
|
315
|
+
assert drain(q)[-1] is None
|
|
316
|
+
assert relay.subscriber_count() == 0
|
|
317
|
+
relay.start('audio/webm')
|
|
318
|
+
assert relay.init_segment is None
|
|
319
|
+
q2 = relay.subscribe()
|
|
320
|
+
assert relay._subs[q2] == 'live' # nothing cached yet: early again
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def test_unsubscribe_stops_delivery():
|
|
324
|
+
relay = web_ui._LiveContainerRelay()
|
|
325
|
+
relay.start('audio/webm')
|
|
326
|
+
q = relay.subscribe()
|
|
327
|
+
relay.unsubscribe(q)
|
|
328
|
+
relay.feed(b'x')
|
|
329
|
+
assert drain(q) == []
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def test_feed_is_safe_under_concurrent_subscribe():
|
|
333
|
+
relay = web_ui._LiveContainerRelay()
|
|
334
|
+
relay.start('audio/webm')
|
|
335
|
+
relay.feed(webm_init() + webm_cluster())
|
|
336
|
+
stop = threading.Event()
|
|
337
|
+
seen = []
|
|
338
|
+
def joiner():
|
|
339
|
+
while not stop.is_set():
|
|
340
|
+
q = relay.subscribe()
|
|
341
|
+
seen.append(q)
|
|
342
|
+
if len(seen) > 50:
|
|
343
|
+
relay.unsubscribe(seen.pop(0))
|
|
344
|
+
t = threading.Thread(target=joiner, daemon=True)
|
|
345
|
+
t.start()
|
|
346
|
+
for _ in range(300):
|
|
347
|
+
relay.feed(webm_cluster())
|
|
348
|
+
stop.set(); t.join(2)
|
|
349
|
+
# every reader that got anything past its init got a cluster start
|
|
350
|
+
for q in seen:
|
|
351
|
+
items = drain(q)
|
|
352
|
+
if len(items) > 1:
|
|
353
|
+
assert items[1][:4] == CLUSTER
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
# ── end to end against a real server ───────────────────────────────────
|
|
357
|
+
class Listener:
|
|
358
|
+
"""A raw-socket reader of /api/orbit-audio: the socket plus whatever
|
|
359
|
+
body bytes came in on the same read as the headers (a late joiner's
|
|
360
|
+
init segment is written the instant the headers are)."""
|
|
361
|
+
def __init__(self, sock, leftover):
|
|
362
|
+
self.sock = sock
|
|
363
|
+
self.leftover = leftover
|
|
364
|
+
|
|
365
|
+
def close(self):
|
|
366
|
+
self.sock.close()
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def audio_connect(url, path='/api/orbit-audio'):
|
|
370
|
+
host, port = _hostport(url)
|
|
371
|
+
s = socket.create_connection((host, port), timeout=5)
|
|
372
|
+
s.sendall(f'GET {path} HTTP/1.1\r\nHost: {host}:{port}\r\n\r\n'.encode())
|
|
373
|
+
raw = _read_until(s, b'\r\n\r\n')
|
|
374
|
+
head, leftover = raw.split(b'\r\n\r\n', 1)
|
|
375
|
+
return Listener(s, leftover), head
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
def recv_exact(listener, n, timeout=5):
|
|
379
|
+
listener.sock.settimeout(timeout)
|
|
380
|
+
buf = listener.leftover
|
|
381
|
+
while len(buf) < n:
|
|
382
|
+
chunk = listener.sock.recv(65536)
|
|
383
|
+
assert chunk, 'listener connection closed'
|
|
384
|
+
buf += chunk
|
|
385
|
+
listener.leftover = buf[n:]
|
|
386
|
+
return buf[:n]
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
def wait_party(url, pred, timeout=3.0):
|
|
390
|
+
deadline = time.time() + timeout
|
|
391
|
+
p = None
|
|
392
|
+
while time.time() < deadline:
|
|
393
|
+
p = http_get_json(f'{url}/api/party')
|
|
394
|
+
if pred(p['stream']):
|
|
395
|
+
return p['stream']
|
|
396
|
+
time.sleep(0.05)
|
|
397
|
+
pytest.fail(f'party never matched; last seen: {p}')
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
def test_audio_is_404_until_a_sender_opens(web_server):
|
|
401
|
+
s, head = audio_connect(web_server)
|
|
402
|
+
assert head.startswith(b'HTTP/1.1 404')
|
|
403
|
+
s.close()
|
|
404
|
+
st = http_get_json(f'{web_server}/api/party')['stream']
|
|
405
|
+
assert st['audio'] is False and st['audio_url'] == '/api/orbit-audio'
|
|
406
|
+
st = http_get_json(f'{web_server}/api/orbit-stream')
|
|
407
|
+
assert st['audio'] is False and st['audio_url'].endswith('/api/orbit-audio')
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
def test_sender_blobs_come_out_of_the_audio_endpoint_with_the_senders_mime(web_server):
|
|
411
|
+
ws = ws_connect(web_server, '/api/orbit-audio-ws?mime=audio%2Fwebm%3Bcodecs%3Dopus')
|
|
412
|
+
try:
|
|
413
|
+
st = wait_party(web_server, lambda s: s['audio'])
|
|
414
|
+
assert st['audio_mime'] == 'audio/webm;codecs=opus' and st['audio_since'] > 0
|
|
415
|
+
init, c1 = webm_init(), webm_cluster()
|
|
416
|
+
listener, head = audio_connect(web_server) # early: gets everything
|
|
417
|
+
assert head.startswith(b'HTTP/1.1 200')
|
|
418
|
+
assert b'Content-Type: audio/webm;codecs=opus' in head
|
|
419
|
+
assert b'Content-Length' not in head
|
|
420
|
+
assert b'Connection: close' in head
|
|
421
|
+
ws.sendall(client_frame(init + c1))
|
|
422
|
+
assert recv_exact(listener, len(init) + len(c1)) == init + c1
|
|
423
|
+
# a late one, joining mid-cluster: init segment, then the next cluster
|
|
424
|
+
c2 = webm_cluster()
|
|
425
|
+
ws.sendall(client_frame(c2[:20]))
|
|
426
|
+
recv_exact(listener, 20)
|
|
427
|
+
late, head = audio_connect(web_server)
|
|
428
|
+
assert head.startswith(b'HTTP/1.1 200')
|
|
429
|
+
c3 = webm_cluster()
|
|
430
|
+
ws.sendall(client_frame(c2[20:] + c3))
|
|
431
|
+
assert recv_exact(late, len(init) + len(c3)) == init + c3
|
|
432
|
+
assert recv_exact(listener, len(c2) - 20 + len(c3)) == c2[20:] + c3
|
|
433
|
+
assert http_get_json(f'{web_server}/api/orbit-stream')['audio_listeners'] == 2
|
|
434
|
+
listener.close(); late.close()
|
|
435
|
+
finally:
|
|
436
|
+
ws.close()
|
|
437
|
+
wait_party(web_server, lambda s: not s['audio'])
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
def test_sender_going_away_ends_every_listener(web_server):
|
|
441
|
+
ws = ws_connect(web_server, '/api/orbit-audio-ws?mime=audio/ogg')
|
|
442
|
+
wait_party(web_server, lambda s: s['audio'])
|
|
443
|
+
listener, head = audio_connect(web_server)
|
|
444
|
+
assert b'Content-Type: audio/ogg' in head
|
|
445
|
+
ws.close()
|
|
446
|
+
listener.sock.settimeout(5)
|
|
447
|
+
assert listener.leftover == b'' and listener.sock.recv(1024) == b'' # EOF: Connection: close honoured
|
|
448
|
+
listener.close()
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
def test_unknown_mime_is_refused_before_the_upgrade(web_server):
|
|
452
|
+
host, port = _hostport(web_server)
|
|
453
|
+
s = socket.create_connection((host, port), timeout=5)
|
|
454
|
+
s.sendall((f'GET /api/orbit-audio-ws?mime=video/mp4 HTTP/1.1\r\nHost: {host}:{port}\r\n'
|
|
455
|
+
'Upgrade: websocket\r\nConnection: Upgrade\r\n'
|
|
456
|
+
'Sec-WebSocket-Key: AAAAAAAAAAAAAAAAAAAAAA==\r\nSec-WebSocket-Version: 13\r\n\r\n').encode())
|
|
457
|
+
head = _read_until(s, b'\r\n\r\n')
|
|
458
|
+
assert head.startswith(b'HTTP/1.1 400')
|
|
459
|
+
s.close()
|
|
460
|
+
assert http_get_json(f'{web_server}/api/party')['stream']['audio'] is False
|
|
461
|
+
|
|
462
|
+
|
|
463
|
+
def test_audio_endpoint_takes_the_token_in_the_query(web_server, monkeypatch):
|
|
464
|
+
monkeypatch.setattr(web_ui, 'AUTH_TOKEN', 'admin-tok')
|
|
465
|
+
monkeypatch.setattr(web_ui, 'STREAM_TOKEN', 'guest-tok')
|
|
466
|
+
s, head = audio_connect(web_server)
|
|
467
|
+
assert head.startswith(b'HTTP/1.1 401')
|
|
468
|
+
s.close()
|
|
469
|
+
s, head = audio_connect(web_server, '/api/orbit-audio?token=guest-tok')
|
|
470
|
+
assert head.startswith(b'HTTP/1.1 404') # past auth; just no audio yet
|
|
471
|
+
s.close()
|