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.
Files changed (30) hide show
  1. {weed_cli-2.5.4 → weed_cli-2.6.0}/PKG-INFO +44 -10
  2. {weed_cli-2.5.4 → weed_cli-2.6.0}/README.md +43 -9
  3. {weed_cli-2.5.4 → weed_cli-2.6.0}/pyproject.toml +1 -1
  4. weed_cli-2.6.0/tests/test_orbit_audio.py +471 -0
  5. {weed_cli-2.5.4 → weed_cli-2.6.0}/tests/test_web_ui_api.py +27 -0
  6. {weed_cli-2.5.4 → weed_cli-2.6.0}/web_ui.py +524 -20
  7. {weed_cli-2.5.4 → weed_cli-2.6.0}/weed_cli.egg-info/PKG-INFO +44 -10
  8. {weed_cli-2.5.4 → weed_cli-2.6.0}/weed_cli.egg-info/SOURCES.txt +1 -0
  9. {weed_cli-2.5.4 → weed_cli-2.6.0}/LICENSE +0 -0
  10. {weed_cli-2.5.4 → weed_cli-2.6.0}/dht.py +0 -0
  11. {weed_cli-2.5.4 → weed_cli-2.6.0}/discovery_relay.py +0 -0
  12. {weed_cli-2.5.4 → weed_cli-2.6.0}/lightning_settle.py +0 -0
  13. {weed_cli-2.5.4 → weed_cli-2.6.0}/node.py +0 -0
  14. {weed_cli-2.5.4 → weed_cli-2.6.0}/poc_reputation.py +0 -0
  15. {weed_cli-2.5.4 → weed_cli-2.6.0}/setup.cfg +0 -0
  16. {weed_cli-2.5.4 → weed_cli-2.6.0}/shell.py +0 -0
  17. {weed_cli-2.5.4 → weed_cli-2.6.0}/tests/test_dht.py +0 -0
  18. {weed_cli-2.5.4 → weed_cli-2.6.0}/tests/test_discovery_relay.py +0 -0
  19. {weed_cli-2.5.4 → weed_cli-2.6.0}/tests/test_host_live_reload.py +0 -0
  20. {weed_cli-2.5.4 → weed_cli-2.6.0}/tests/test_node_manifest.py +0 -0
  21. {weed_cli-2.5.4 → weed_cli-2.6.0}/tests/test_orbit_ws.py +0 -0
  22. {weed_cli-2.5.4 → weed_cli-2.6.0}/tests/test_timing_challenge.py +0 -0
  23. {weed_cli-2.5.4 → weed_cli-2.6.0}/tests/test_tunnel_failover.py +0 -0
  24. {weed_cli-2.5.4 → weed_cli-2.6.0}/tests/testutil.py +0 -0
  25. {weed_cli-2.5.4 → weed_cli-2.6.0}/tunnel_relay.py +0 -0
  26. {weed_cli-2.5.4 → weed_cli-2.6.0}/weed.py +0 -0
  27. {weed_cli-2.5.4 → weed_cli-2.6.0}/weed_cli.egg-info/dependency_links.txt +0 -0
  28. {weed_cli-2.5.4 → weed_cli-2.6.0}/weed_cli.egg-info/entry_points.txt +0 -0
  29. {weed_cli-2.5.4 → weed_cli-2.6.0}/weed_cli.egg-info/requires.txt +0 -0
  30. {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.5.4
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, ←/→ cycle modes (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. The stream keeps running while the visualizer is closed,
241
- minimised to PIP, or in a background tab. Picture only audio stays
242
- wherever the browser is playing it.
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. Every row, plugin modes
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 picture only, as MJPEG: fine for VLC, an
537
- IP-camera app or the party page, but no audio travels with it and
538
- there's no HLS/MP4 endpoint, so a stock Roku or smart-TV player
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, ←/→ cycle modes (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. The stream keeps running while the visualizer is closed,
209
- minimised to PIP, or in a background tab. Picture only audio stays
210
- wherever the browser is playing it.
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. Every row, plugin modes
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 picture only, as MJPEG: fine for VLC, an
505
- IP-camera app or the party page, but no audio travels with it and
506
- there's no HLS/MP4 endpoint, so a stock Roku or smart-TV player
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.5.4"
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()