weed-cli 2.6.2__tar.gz → 2.7.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 (31) hide show
  1. {weed_cli-2.6.2 → weed_cli-2.7.0}/PKG-INFO +48 -7
  2. {weed_cli-2.6.2 → weed_cli-2.7.0}/README.md +47 -6
  3. {weed_cli-2.6.2 → weed_cli-2.7.0}/pyproject.toml +1 -1
  4. weed_cli-2.7.0/tests/test_kodi_addon.py +267 -0
  5. {weed_cli-2.6.2 → weed_cli-2.7.0}/tests/test_orbit_audio.py +22 -0
  6. {weed_cli-2.6.2 → weed_cli-2.7.0}/web_ui.py +116 -3
  7. {weed_cli-2.6.2 → weed_cli-2.7.0}/weed_cli.egg-info/PKG-INFO +48 -7
  8. {weed_cli-2.6.2 → weed_cli-2.7.0}/weed_cli.egg-info/SOURCES.txt +1 -0
  9. {weed_cli-2.6.2 → weed_cli-2.7.0}/LICENSE +0 -0
  10. {weed_cli-2.6.2 → weed_cli-2.7.0}/dht.py +0 -0
  11. {weed_cli-2.6.2 → weed_cli-2.7.0}/discovery_relay.py +0 -0
  12. {weed_cli-2.6.2 → weed_cli-2.7.0}/lightning_settle.py +0 -0
  13. {weed_cli-2.6.2 → weed_cli-2.7.0}/node.py +0 -0
  14. {weed_cli-2.6.2 → weed_cli-2.7.0}/poc_reputation.py +0 -0
  15. {weed_cli-2.6.2 → weed_cli-2.7.0}/setup.cfg +0 -0
  16. {weed_cli-2.6.2 → weed_cli-2.7.0}/shell.py +0 -0
  17. {weed_cli-2.6.2 → weed_cli-2.7.0}/tests/test_dht.py +0 -0
  18. {weed_cli-2.6.2 → weed_cli-2.7.0}/tests/test_discovery_relay.py +0 -0
  19. {weed_cli-2.6.2 → weed_cli-2.7.0}/tests/test_host_live_reload.py +0 -0
  20. {weed_cli-2.6.2 → weed_cli-2.7.0}/tests/test_node_manifest.py +0 -0
  21. {weed_cli-2.6.2 → weed_cli-2.7.0}/tests/test_orbit_ws.py +0 -0
  22. {weed_cli-2.6.2 → weed_cli-2.7.0}/tests/test_timing_challenge.py +0 -0
  23. {weed_cli-2.6.2 → weed_cli-2.7.0}/tests/test_tunnel_failover.py +0 -0
  24. {weed_cli-2.6.2 → weed_cli-2.7.0}/tests/test_web_ui_api.py +0 -0
  25. {weed_cli-2.6.2 → weed_cli-2.7.0}/tests/testutil.py +0 -0
  26. {weed_cli-2.6.2 → weed_cli-2.7.0}/tunnel_relay.py +0 -0
  27. {weed_cli-2.6.2 → weed_cli-2.7.0}/weed.py +0 -0
  28. {weed_cli-2.6.2 → weed_cli-2.7.0}/weed_cli.egg-info/dependency_links.txt +0 -0
  29. {weed_cli-2.6.2 → weed_cli-2.7.0}/weed_cli.egg-info/entry_points.txt +0 -0
  30. {weed_cli-2.6.2 → weed_cli-2.7.0}/weed_cli.egg-info/requires.txt +0 -0
  31. {weed_cli-2.6.2 → weed_cli-2.7.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.6.2
3
+ Version: 2.7.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
@@ -56,6 +56,7 @@ output (not simulated) — see [Status](#status) for what's verified.
56
56
  - [Interactive shell](#interactive-shell)
57
57
  - [Web UI](#web-ui)
58
58
  - [Orbit Visualizer](#orbit-visualizer)
59
+ - [Kodi add-on](#kodi-add-on)
59
60
  - [Orbit Visualizer plugins](#orbit-visualizer-plugins)
60
61
  - [Docker](#docker)
61
62
  - [Core mechanisms](#core-mechanisms)
@@ -225,7 +226,7 @@ The player's 🌀 button opens the **Orbit Visualizer**: a canvas driven by
225
226
  the track's audio (Web Audio analyser) and a low-res sample of its
226
227
  picture. Eleven built-in modes — Tunnel, Bars, Mirror, Scope, Spiral,
227
228
  Pixels, ASCII (six character sets, natural or neon), Plasma, Kaleido,
228
- Particles, Freefall — plus the seventeen in `web/orbit_extras.js` (next
229
+ Particles, Freefall — plus the nineteen in `web/orbit_extras.js` (next
229
230
  section). Click the lit mode again for the plain video. Speed, React and
230
231
  Zoom retune every mode; every setting persists in the browser.
231
232
 
@@ -255,6 +256,11 @@ Zoom retune every mode; every setting persists in the browser.
255
256
  guest party page shows a 🔊 listen button; VLC takes both with
256
257
  `vlc --demux=mjpeg <view-url> --input-slave=<audio-url>`; OBS takes the
257
258
  audio URL as a second Media Source. Safari/iOS can't play WebM audio.
259
+ With ffmpeg on the node (the Docker image has it; otherwise
260
+ `$WEED_FFMPEG` or PATH), `/api/orbit-mux` is picture and audio as one
261
+ live Matroska stream, stream-copied, no transcoding -- for Kodi and
262
+ anything else that can't pair two URLs. Roku still can't: it wants
263
+ H.264, and this is MJPEG in a box.
258
264
  - **MIDI** (🎹): an AKAI MPK mini or any Web MIDI controller
259
265
  (Chrome/Edge/Firefox). Pads pick modes and fire actions, knobs turn
260
266
  the sliders or sweep through modes/transitions/character sets;
@@ -286,6 +292,36 @@ Zoom retune every mode; every setting persists in the browser.
286
292
  slider in the picker, and the pairing is remembered per track. The
287
293
  picker has a search box for big libraries.
288
294
 
295
+ ### Kodi add-on
296
+
297
+ `kodi/plugin.video.weed` is a Kodi add-on (Kodi 19 Matrix or later, any
298
+ platform) that puts the node on a TV without streaming anything: it
299
+ browses the node over the same web API the browser uses and plays the
300
+ downloaded files through Kodi's own player, which handles every codec.
301
+ Screens: **Downloads** (newest first, extensions dropped), **Playlists**
302
+ with play-all, **Tags** as folders, **Party** (the vote list, select to
303
+ +1), **Autopilot** (the node's weighted least-played pick), and the
304
+ **live Orbit feed** while the visualizer is streaming: picture and audio
305
+ together (`/api/orbit-mux`, needs ffmpeg on the node), or the picture or
306
+ the audio alone (the audio one lights up Kodi's own visualisers). Plays are counted on
307
+ the node like the browser's.
308
+
309
+ ```bash
310
+ make kodi # -> dist/plugin.video.weed-<ver>.zip
311
+ ```
312
+
313
+ On the Kodi box: Settings → Add-ons → *Unknown sources* on → *Install
314
+ from zip file* → the zip (copy it over, or share it with the node's
315
+ `/share`). Then open the add-on's settings and set the node URL, for
316
+ example `http://192.168.1.137:8080`, and the token if the node runs with
317
+ `--auth-token`. The add-on hands Kodi the `/api/stream/<job_id>` URLs
318
+ with the token in the query, so seeking and resume work as for any file.
319
+ An https node works too: the add-on accepts the node's self-signed
320
+ certificate for its API calls (a setting, on by default), and sends the
321
+ player to the plain stream port the node advertises
322
+ (`--stream-plain-port`), falling back to https with Kodi's own
323
+ `verifypeer=false` URL option when there is no plain port.
324
+
289
325
  ### Orbit Visualizer plugins
290
326
 
291
327
  The visualizer's modes and its Fade transitions are both open registries:
@@ -299,10 +335,12 @@ mode selector; a registered transition gets a Fade option and a turn in
299
335
  taking the draw loop down. `web/orbit_extras.js` (loaded by default) is
300
336
  built entirely on that API and adds Halftone, Lava, Terrain, Rain,
301
337
  Lissajous, Vinyl 33, Cube, VHS, Win95, Joy Division, Spectrogram, Stained
302
- glass, Fireworks, Screensaver, Slit-scan, Skyline and Globe, plus the
303
- Melt, Dissolve, Iris, Shatter, Wave, Spin, Zoom blur, RGB split, VHS,
304
- Win95, Blinds, Flip tiles, CRT off, Droplet, Blur, Slide and Flash
305
- transitions (Globe's coastlines are Natural Earth's public-domain 110m
338
+ glass, Fireworks, Screensaver, Slit-scan, Skyline, Globe, Starfield and
339
+ Spectrum 3D, plus the Melt, Dissolve, Iris, Shatter, Wave, Spin, Zoom
340
+ blur, RGB split, VHS, Win95, Blinds, Flip tiles, CRT off, Droplet, Blur,
341
+ Slide, Flash, Desktop cube, Carousel and Doors transitions (the 3D ones
342
+ draw perspective faces as runs of affine strips, since Canvas 2D has no
343
+ projection of its own; Globe's coastlines are Natural Earth's public-domain 110m
306
344
  land set, simplified into `web/land.json`); `web/orbit_plugin_example.js`
307
345
  is a minimal one to copy.
308
346
 
@@ -523,7 +561,10 @@ just designed:
523
561
  - Kademlia DHT discovery, survives the announcing node going offline
524
562
  - Local web UI with live progress, QR onboarding, HTTP range streaming,
525
563
  optional two-tier token auth, and the guest party page with voting
526
- - Orbit Visualizer: 28 modes and 23 transitions behind a plugin API,
564
+ - Kodi add-on (`kodi/plugin.video.weed`): downloads, playlists, tags,
565
+ party votes, Autopilot pick and the live feeds on a TV through Kodi's
566
+ own player, 9 tests against a real node with Kodi stubbed.
567
+ - Orbit Visualizer: 30 modes and 26 transitions behind a plugin API,
527
568
  MJPEG network stream (VLC/Roku), Web MIDI control with keymap files,
528
569
  video swap — 44 Playwright tests against real Chromium (`tests/e2e/`)
529
570
  - Containerized node (`Dockerfile.node`, `docker-compose.node.yml`),
@@ -24,6 +24,7 @@ output (not simulated) — see [Status](#status) for what's verified.
24
24
  - [Interactive shell](#interactive-shell)
25
25
  - [Web UI](#web-ui)
26
26
  - [Orbit Visualizer](#orbit-visualizer)
27
+ - [Kodi add-on](#kodi-add-on)
27
28
  - [Orbit Visualizer plugins](#orbit-visualizer-plugins)
28
29
  - [Docker](#docker)
29
30
  - [Core mechanisms](#core-mechanisms)
@@ -193,7 +194,7 @@ The player's 🌀 button opens the **Orbit Visualizer**: a canvas driven by
193
194
  the track's audio (Web Audio analyser) and a low-res sample of its
194
195
  picture. Eleven built-in modes — Tunnel, Bars, Mirror, Scope, Spiral,
195
196
  Pixels, ASCII (six character sets, natural or neon), Plasma, Kaleido,
196
- Particles, Freefall — plus the seventeen in `web/orbit_extras.js` (next
197
+ Particles, Freefall — plus the nineteen in `web/orbit_extras.js` (next
197
198
  section). Click the lit mode again for the plain video. Speed, React and
198
199
  Zoom retune every mode; every setting persists in the browser.
199
200
 
@@ -223,6 +224,11 @@ Zoom retune every mode; every setting persists in the browser.
223
224
  guest party page shows a 🔊 listen button; VLC takes both with
224
225
  `vlc --demux=mjpeg <view-url> --input-slave=<audio-url>`; OBS takes the
225
226
  audio URL as a second Media Source. Safari/iOS can't play WebM audio.
227
+ With ffmpeg on the node (the Docker image has it; otherwise
228
+ `$WEED_FFMPEG` or PATH), `/api/orbit-mux` is picture and audio as one
229
+ live Matroska stream, stream-copied, no transcoding -- for Kodi and
230
+ anything else that can't pair two URLs. Roku still can't: it wants
231
+ H.264, and this is MJPEG in a box.
226
232
  - **MIDI** (🎹): an AKAI MPK mini or any Web MIDI controller
227
233
  (Chrome/Edge/Firefox). Pads pick modes and fire actions, knobs turn
228
234
  the sliders or sweep through modes/transitions/character sets;
@@ -254,6 +260,36 @@ Zoom retune every mode; every setting persists in the browser.
254
260
  slider in the picker, and the pairing is remembered per track. The
255
261
  picker has a search box for big libraries.
256
262
 
263
+ ### Kodi add-on
264
+
265
+ `kodi/plugin.video.weed` is a Kodi add-on (Kodi 19 Matrix or later, any
266
+ platform) that puts the node on a TV without streaming anything: it
267
+ browses the node over the same web API the browser uses and plays the
268
+ downloaded files through Kodi's own player, which handles every codec.
269
+ Screens: **Downloads** (newest first, extensions dropped), **Playlists**
270
+ with play-all, **Tags** as folders, **Party** (the vote list, select to
271
+ +1), **Autopilot** (the node's weighted least-played pick), and the
272
+ **live Orbit feed** while the visualizer is streaming: picture and audio
273
+ together (`/api/orbit-mux`, needs ffmpeg on the node), or the picture or
274
+ the audio alone (the audio one lights up Kodi's own visualisers). Plays are counted on
275
+ the node like the browser's.
276
+
277
+ ```bash
278
+ make kodi # -> dist/plugin.video.weed-<ver>.zip
279
+ ```
280
+
281
+ On the Kodi box: Settings → Add-ons → *Unknown sources* on → *Install
282
+ from zip file* → the zip (copy it over, or share it with the node's
283
+ `/share`). Then open the add-on's settings and set the node URL, for
284
+ example `http://192.168.1.137:8080`, and the token if the node runs with
285
+ `--auth-token`. The add-on hands Kodi the `/api/stream/<job_id>` URLs
286
+ with the token in the query, so seeking and resume work as for any file.
287
+ An https node works too: the add-on accepts the node's self-signed
288
+ certificate for its API calls (a setting, on by default), and sends the
289
+ player to the plain stream port the node advertises
290
+ (`--stream-plain-port`), falling back to https with Kodi's own
291
+ `verifypeer=false` URL option when there is no plain port.
292
+
257
293
  ### Orbit Visualizer plugins
258
294
 
259
295
  The visualizer's modes and its Fade transitions are both open registries:
@@ -267,10 +303,12 @@ mode selector; a registered transition gets a Fade option and a turn in
267
303
  taking the draw loop down. `web/orbit_extras.js` (loaded by default) is
268
304
  built entirely on that API and adds Halftone, Lava, Terrain, Rain,
269
305
  Lissajous, Vinyl 33, Cube, VHS, Win95, Joy Division, Spectrogram, Stained
270
- glass, Fireworks, Screensaver, Slit-scan, Skyline and Globe, plus the
271
- Melt, Dissolve, Iris, Shatter, Wave, Spin, Zoom blur, RGB split, VHS,
272
- Win95, Blinds, Flip tiles, CRT off, Droplet, Blur, Slide and Flash
273
- transitions (Globe's coastlines are Natural Earth's public-domain 110m
306
+ glass, Fireworks, Screensaver, Slit-scan, Skyline, Globe, Starfield and
307
+ Spectrum 3D, plus the Melt, Dissolve, Iris, Shatter, Wave, Spin, Zoom
308
+ blur, RGB split, VHS, Win95, Blinds, Flip tiles, CRT off, Droplet, Blur,
309
+ Slide, Flash, Desktop cube, Carousel and Doors transitions (the 3D ones
310
+ draw perspective faces as runs of affine strips, since Canvas 2D has no
311
+ projection of its own; Globe's coastlines are Natural Earth's public-domain 110m
274
312
  land set, simplified into `web/land.json`); `web/orbit_plugin_example.js`
275
313
  is a minimal one to copy.
276
314
 
@@ -491,7 +529,10 @@ just designed:
491
529
  - Kademlia DHT discovery, survives the announcing node going offline
492
530
  - Local web UI with live progress, QR onboarding, HTTP range streaming,
493
531
  optional two-tier token auth, and the guest party page with voting
494
- - Orbit Visualizer: 28 modes and 23 transitions behind a plugin API,
532
+ - Kodi add-on (`kodi/plugin.video.weed`): downloads, playlists, tags,
533
+ party votes, Autopilot pick and the live feeds on a TV through Kodi's
534
+ own player, 9 tests against a real node with Kodi stubbed.
535
+ - Orbit Visualizer: 30 modes and 26 transitions behind a plugin API,
495
536
  MJPEG network stream (VLC/Roku), Web MIDI control with keymap files,
496
537
  video swap — 44 Playwright tests against real Chromium (`tests/e2e/`)
497
538
  - Containerized node (`Dockerfile.node`, `docker-compose.node.yml`),
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "weed-cli"
7
- version = "2.6.2"
7
+ version = "2.7.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,267 @@
1
+ """The Kodi add-on (kodi/plugin.video.weed) against a real, isolated
2
+ web_ui server -- same fixture as the API tests. Kodi itself isn't here:
3
+ the add-on keeps every decision in resources/lib/plugin.py behind a
4
+ small ui object, and FakeUI below records what a real Kodi would have
5
+ been told to draw or play. default.py (the xbmc glue) is only checked
6
+ for syntax and the URL it builds, with the xbmc modules stubbed."""
7
+ import importlib.util
8
+ import os
9
+ import sys
10
+ import types
11
+
12
+ import pytest
13
+
14
+ import web_ui
15
+ from testutil import http_get_json, http_post_json
16
+
17
+ ADDON = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), 'kodi', 'plugin.video.weed')
18
+ sys.path.insert(0, ADDON)
19
+ from resources.lib import plugin, weedapi # noqa: E402
20
+
21
+
22
+ class FakeUI:
23
+ def __init__(self, server, token=None):
24
+ self._settings = {'server': server, 'token': token or ''}
25
+ self.folders, self.items, self.played, self.queued, self.notices, self.ended = [], [], [], [], [], []
26
+
27
+ def settings(self): return dict(self._settings)
28
+ def folder(self, label, action, **params): self.folders.append((label, action, params))
29
+ def media(self, label, url, info, action, **params): self.items.append((label, url, info, action, params))
30
+ def end(self, content=None): self.ended.append(content)
31
+ def play(self, url, label, info): self.played.append((url, label, info))
32
+ def play_all(self, entries): self.queued.append(entries)
33
+ def notify(self, message): self.notices.append(message)
34
+
35
+
36
+ def _seed(n=3):
37
+ with web_ui._lock:
38
+ for i in range(n):
39
+ h = chr(ord('a') + i) * 64
40
+ web_ui._library['downloads'][h] = {
41
+ 'content_hash': h, 'job_id': 'job%d' % i, 'path': '/x/%d.%s' % (i, 'mp3' if i == 2 else 'mp4'),
42
+ 'title': 'Track %d.%s' % (i, 'mp3' if i == 2 else 'mp4'), 'downloaded_at': 100 + i, 'size': 1000 * (i + 1),
43
+ 'play_count': i, 'tags': ['chill'] if i < 2 else ['party'],
44
+ }
45
+ web_ui._library['downloads']['unfinished'] = {'content_hash': 'unfinished', 'title': 'Not yet'}
46
+ web_ui._library['playlists'] = [{'id': 'pl1', 'name': 'Late', 'items': [
47
+ {'content_hash': 'b' * 64, 'title': 'Track 1.mp4'}, {'content_hash': 'a' * 64, 'title': 'Track 0.mp4'}, {'content_hash': 'zz', 'title': 'gone'}]}]
48
+ web_ui._save_library()
49
+
50
+
51
+ def test_display_title_drops_only_a_media_extension():
52
+ assert weedapi.display_title('Late Night Mix.mp3') == 'Late Night Mix'
53
+ assert weedapi.display_title('Track 8.MP4') == 'Track 8'
54
+ assert weedapi.display_title('v1.2 notes') == 'v1.2 notes'
55
+ assert weedapi.display_title(None) == ''
56
+
57
+
58
+ def test_root_and_downloads_list_finished_tracks_newest_first(web_server):
59
+ _seed()
60
+ ui = FakeUI(web_server)
61
+ plugin.Plugin(ui).run()
62
+ assert [f[1] for f in ui.folders] == ['downloads', 'playlists', 'tags', 'party', 'live', 'live', 'live', 'autopilot']
63
+ ui = FakeUI(web_server)
64
+ plugin.Plugin(ui).run(action='downloads')
65
+ assert [m[0] for m in ui.items] == ['Track 2', 'Track 1', 'Track 0'] # newest first, extension dropped, no unfinished
66
+ label, url, info, action, params = ui.items[0]
67
+ assert url == web_server + '/api/stream/job2' and action == 'play'
68
+ assert params['job_id'] == 'job2' and params['content_hash'] == 'c' * 64
69
+ assert info['mediatype'] == 'song' and info['playcount'] == 2 # an mp3 is a song, and the count comes through
70
+ assert ui.ended == ['musicvideos']
71
+
72
+
73
+ def test_tags_playlists_and_play_all(web_server):
74
+ _seed()
75
+ ui = FakeUI(web_server)
76
+ plugin.Plugin(ui).run(action='tags')
77
+ assert [f[0] for f in ui.folders] == ['chill (2)', 'party (1)']
78
+ assert ui.folders[0][2] == {'tag': 'chill'}
79
+ ui = FakeUI(web_server)
80
+ plugin.Plugin(ui).run(action='downloads', tag='chill', sort='title')
81
+ assert [m[0] for m in ui.items] == ['Track 0', 'Track 1']
82
+ ui = FakeUI(web_server)
83
+ plugin.Plugin(ui).run(action='playlists')
84
+ assert ui.folders == [('Late (3)', 'playlist', {'id': 'pl1'})]
85
+ ui = FakeUI(web_server)
86
+ plugin.Plugin(ui).run(action='playlist', id='pl1')
87
+ assert ui.folders[0][1] == 'playall'
88
+ assert [m[0] for m in ui.items] == ['Track 1', 'Track 0'] # playlist order, the undownloaded one skipped
89
+ ui = FakeUI(web_server)
90
+ plugin.Plugin(ui).run(action='playall', id='pl1')
91
+ assert [e[0] for e in ui.queued[0]] == [web_server + '/api/stream/job1', web_server + '/api/stream/job0']
92
+
93
+
94
+ def test_play_resolves_the_stream_url_and_counts_the_play(web_server):
95
+ _seed()
96
+ ui = FakeUI(web_server)
97
+ plugin.Plugin(ui).run(action='play', job_id='job0', content_hash='a' * 64, title='Track 0')
98
+ assert ui.played == [(web_server + '/api/stream/job0', 'Track 0', {'title': 'Track 0'})]
99
+ lib = http_get_json(web_server + '/api/library')
100
+ rec = next(d for d in lib['downloads'] if d['content_hash'] == 'a' * 64)
101
+ assert rec['play_count'] == 1 and rec['last_played'] > 0
102
+ assert lib['history'][0]['content_hash'] == 'a' * 64
103
+
104
+
105
+ def test_autopilot_pick_never_chooses_nothing_and_counts(web_server):
106
+ _seed()
107
+ ui = FakeUI(web_server)
108
+ plugin.Plugin(ui).run(action='autopilot')
109
+ assert len(ui.played) == 1 and ui.played[0][0].startswith(web_server + '/api/stream/job')
110
+ lib = http_get_json(web_server + '/api/library')
111
+ assert sum(d.get('play_count', 0) for d in lib['downloads']) == 0 + 1 + 2 + 1 # one more play somewhere
112
+
113
+
114
+ def test_token_goes_in_the_header_and_the_stream_query(web_server, monkeypatch):
115
+ _seed()
116
+ monkeypatch.setattr(web_ui, 'AUTH_TOKEN', 'admin-tok')
117
+ ui = FakeUI(web_server, token='admin-tok')
118
+ plugin.Plugin(ui).run(action='downloads')
119
+ assert ui.items and ui.items[0][1].endswith('/api/stream/job2?token=admin-tok')
120
+ # a wrong token is a clear message, not a stack trace
121
+ ui = FakeUI(web_server, token='nope')
122
+ plugin.Plugin(ui).run(action='downloads')
123
+ assert ui.items == [] and 'token' in ui.notices[0]
124
+
125
+
126
+ def test_live_feeds_and_party_when_nothing_is_streaming(web_server):
127
+ _seed()
128
+ ui = FakeUI(web_server)
129
+ plugin.Plugin(ui).run(action='live', what='view')
130
+ assert ui.played == [] and 'not streaming' in ui.notices[0]
131
+ ui = FakeUI(web_server)
132
+ plugin.Plugin(ui).run(action='party')
133
+ assert ui.folders and all(f[1] == 'vote' or f[1] == 'party' for f in ui.folders)
134
+ ui = FakeUI(web_server)
135
+ plugin.Plugin(ui).run(action='vote', content_hash='a' * 64)
136
+ assert ui.notices == ['voted']
137
+ party = http_get_json(web_server + '/api/party')
138
+ assert any(t['content_hash'] == 'a' * 64 and (t.get('votes') or t.get('score')) for t in party['tracks'])
139
+
140
+
141
+ def test_no_server_set_is_a_settings_hint_not_a_crash():
142
+ ui = FakeUI('')
143
+ plugin.Plugin(ui).run(action='downloads')
144
+ assert 'settings' in ui.notices[0] and ui.ended == [None]
145
+
146
+
147
+ def test_default_py_builds_plugin_urls_with_stubbed_kodi(monkeypatch):
148
+ """default.py imports the xbmc modules at the top; stub them and check
149
+ the glue: settings come from the add-on, a folder becomes a
150
+ plugin:// URL with the action and params in the query."""
151
+ calls = []
152
+ xbmc = types.SimpleNamespace(log=lambda *a: None, LOGINFO=1, PLAYLIST_VIDEO=1,
153
+ Player=lambda: types.SimpleNamespace(play=lambda *a: calls.append(('play',) + a)),
154
+ PlayList=lambda k: types.SimpleNamespace(clear=lambda: None, add=lambda *a: calls.append(('add',) + a)))
155
+ xbmcaddon = types.SimpleNamespace(Addon=lambda: types.SimpleNamespace(
156
+ getAddonInfo=lambda k: ADDON, getSetting=lambda k: {'server': 'http://node:8080', 'token': 't', 'insecure': 'true'}[k]))
157
+ xbmcgui = types.SimpleNamespace(ListItem=lambda **kw: types.SimpleNamespace(setInfo=lambda *a: None, setProperty=lambda *a: None, **kw),
158
+ Dialog=lambda: types.SimpleNamespace(notification=lambda *a: calls.append(('notify',) + a)), NOTIFICATION_INFO=0)
159
+ xbmcplugin = types.SimpleNamespace(addDirectoryItem=lambda h, url, item, isFolder=False: calls.append(('dir', url, item.label, isFolder)),
160
+ endOfDirectory=lambda h: calls.append(('end',)), setContent=lambda h, c: calls.append(('content', c)),
161
+ setResolvedUrl=lambda h, ok, item: calls.append(('resolved', item.path)))
162
+ for name, mod in (('xbmc', xbmc), ('xbmcaddon', xbmcaddon), ('xbmcgui', xbmcgui), ('xbmcplugin', xbmcplugin)):
163
+ monkeypatch.setitem(sys.modules, name, mod)
164
+ monkeypatch.setattr(sys, 'argv', ['plugin://plugin.video.weed/', '7', '?action=root'])
165
+ spec = importlib.util.spec_from_file_location('weed_default', os.path.join(ADDON, 'default.py'))
166
+ mod = importlib.util.module_from_spec(spec)
167
+ spec.loader.exec_module(mod)
168
+ ui = mod.KodiUI()
169
+ assert ui.settings() == {'server': 'http://node:8080', 'token': 't', 'insecure': True}
170
+ ui.folder('Downloads', 'downloads', tag='chill')
171
+ assert calls[-1] == ('dir', 'plugin://plugin.video.weed/?action=downloads&tag=chill', 'Downloads', True)
172
+ ui.media('Track', 'http://node:8080/api/stream/j1?token=t', {'title': 'Track', 'mediatype': 'musicvideo'}, action='play', job_id='j1', content_hash='h', title='Track')
173
+ assert calls[-1][1] == 'plugin://plugin.video.weed/?action=play&job_id=j1&content_hash=h&title=Track' and calls[-1][3] is False
174
+ ui.end('musicvideos')
175
+ assert calls[-2:] == [('content', 'musicvideos'), ('end',)]
176
+ ui.play('http://node:8080/api/stream/j1?token=t', 'Track', {'title': 'Track'})
177
+ assert calls[-1][0] == 'play' # not invoked as a resolver here
178
+ monkeypatch.setattr(sys, 'argv', ['plugin://plugin.video.weed/', '7', '?action=play&job_id=j1'])
179
+ ui.play('http://node:8080/api/stream/j1?token=t', 'Track', {'title': 'Track'})
180
+ assert calls[-1] == ('resolved', 'http://node:8080/api/stream/j1?token=t')
181
+
182
+
183
+ def test_kodi_own_url_parameters_are_ignored(web_server):
184
+ """Ryan's first launch: Kodi opens a video+audio add-on with
185
+ ?content_type=video, which crashed do_root(). Any parameter a screen
186
+ doesn't declare is dropped, on every screen."""
187
+ _seed()
188
+ ui = FakeUI(web_server)
189
+ plugin.Plugin(ui).run(content_type='video')
190
+ assert [f[1] for f in ui.folders][:2] == ['downloads', 'playlists'] and not ui.notices
191
+ ui = FakeUI(web_server)
192
+ plugin.Plugin(ui).run(action='downloads', content_type='audio', tag='chill')
193
+ assert [m[0] for m in ui.items] == ['Track 1', 'Track 0'] and not ui.notices
194
+
195
+
196
+ def test_live_feed_urls_come_from_the_configured_node_not_its_advertised_plain_port(web_server, monkeypatch):
197
+ """Ryan: "unable to view the orbit stream, the log says it's trying
198
+ 4242, but that's the old port". The node still advertised a plain
199
+ stream port that was no longer exposed; the add-on now builds the feed
200
+ URLs from its own node URL, with the token, and only takes the
201
+ advertised plain URL when the node is https."""
202
+ _seed()
203
+ monkeypatch.setattr(web_ui, '_orbit_ws_connected', True)
204
+ monkeypatch.setattr(web_ui, 'STREAM_PLAIN_PORT', 4242)
205
+ monkeypatch.setattr(web_ui, 'AUTH_TOKEN', 'admin-tok')
206
+ ui = FakeUI(web_server, token='admin-tok')
207
+ plugin.Plugin(ui).run(action='live', what='view')
208
+ assert ui.played and ui.played[0][0] == web_server + '/api/orbit-view?token=admin-tok'
209
+ assert ':4242' not in ui.played[0][0]
210
+ monkeypatch.setitem(web_ui._orbit_audio, 'on', True)
211
+ ui = FakeUI(web_server, token='admin-tok')
212
+ plugin.Plugin(ui).run(action='live', what='audio')
213
+ assert ui.played and ui.played[0][0] == web_server + '/api/orbit-audio?token=admin-tok'
214
+
215
+
216
+ def test_https_node_sends_media_through_its_plain_port_or_skips_verification():
217
+ """Ryan: "seems that I need the tls for it to work, but the firestick
218
+ doesn't have the cert installed". The API calls tolerate the self-
219
+ signed certificate; the player never sees https when the node
220
+ advertises a plain stream port, and gets Kodi's verifypeer=false
221
+ option when it doesn't."""
222
+ import io, json as _json
223
+ class Resp:
224
+ def __init__(self, body): self._b = _json.dumps(body).encode()
225
+ def read(self): return self._b
226
+ def __enter__(self): return self
227
+ def __exit__(self, *a): return False
228
+ seen = []
229
+ def opener_with_plain(req, timeout=None):
230
+ seen.append(req.full_url)
231
+ return Resp({'active': False, 'plain_url': 'http://192.168.1.137:8081/api/orbit-view?token=t', 'audio_plain_url': 'http://192.168.1.137:8081/api/orbit-audio?token=t'})
232
+ api = weedapi.WeedApi('https://192.168.1.137:8080', 't', opener=opener_with_plain)
233
+ assert api.is_tls
234
+ assert api.stream_url('job1') == 'http://192.168.1.137:8081/api/stream/job1?token=t'
235
+ assert api.feed_url('view') == 'http://192.168.1.137:8081/api/orbit-view?token=t'
236
+ assert api.feed_url('audio') == 'http://192.168.1.137:8081/api/orbit-audio?token=t'
237
+ assert seen == ['https://192.168.1.137:8080/api/orbit-stream'] # asked once, then cached
238
+ def opener_no_plain(req, timeout=None):
239
+ return Resp({'active': False, 'plain_url': None, 'audio_plain_url': None})
240
+ api = weedapi.WeedApi('https://node:8080', 't', opener=opener_no_plain)
241
+ assert api.stream_url('job1') == 'https://node:8080/api/stream/job1?token=t|verifypeer=false'
242
+ assert api.feed_url('audio') == 'https://node:8080/api/orbit-audio?token=t|verifypeer=false'
243
+ # a plain http node never touches the advertised plain port at all
244
+ def opener_stale(req, timeout=None):
245
+ return Resp({'active': True, 'plain_url': 'http://node:4242/api/orbit-view'})
246
+ api = weedapi.WeedApi('http://node:8080', None, opener=opener_stale)
247
+ assert api.stream_url('job1') == 'http://node:8080/api/stream/job1'
248
+ assert api.feed_url('view') == 'http://node:8080/api/orbit-view'
249
+
250
+
251
+ def test_muxed_live_feed_needs_audio_and_ffmpeg(web_server, monkeypatch):
252
+ """The picture+audio entry: one Matroska URL when the node has ffmpeg
253
+ and audio is on; a plain notice otherwise."""
254
+ _seed()
255
+ monkeypatch.setattr(web_ui, '_orbit_ws_connected', True)
256
+ ui = FakeUI(web_server)
257
+ plugin.Plugin(ui).run(action='live', what='mux')
258
+ assert ui.played == [] and 'no audio' in ui.notices[0]
259
+ monkeypatch.setitem(web_ui._orbit_audio, 'on', True)
260
+ monkeypatch.setattr(web_ui, '_ffmpeg_path', lambda: None)
261
+ ui = FakeUI(web_server)
262
+ plugin.Plugin(ui).run(action='live', what='mux')
263
+ assert ui.played == [] and 'ffmpeg' in ui.notices[0]
264
+ monkeypatch.setattr(web_ui, '_ffmpeg_path', lambda: '/usr/bin/ffmpeg')
265
+ ui = FakeUI(web_server)
266
+ plugin.Plugin(ui).run(action='live', what='mux')
267
+ assert ui.played == [(web_server + '/api/orbit-mux', 'weed Orbit', {'title': 'weed Orbit', 'mediatype': 'video'})]
@@ -469,3 +469,25 @@ def test_audio_endpoint_takes_the_token_in_the_query(web_server, monkeypatch):
469
469
  s, head = audio_connect(web_server, '/api/orbit-audio?token=guest-tok')
470
470
  assert head.startswith(b'HTTP/1.1 404') # past auth; just no audio yet
471
471
  s.close()
472
+
473
+
474
+ def test_mux_endpoint_says_plainly_why_it_cannot(web_server, monkeypatch):
475
+ from testutil import http_get_json
476
+ import urllib.error, urllib.request, json as _json
477
+ def get(path):
478
+ try:
479
+ with urllib.request.urlopen(web_server + path) as r:
480
+ return r.status, _json.loads(r.read())
481
+ except urllib.error.HTTPError as e:
482
+ return e.code, _json.loads(e.read())
483
+ monkeypatch.setattr(web_ui, '_ffmpeg_path', lambda: None)
484
+ status, body = get('/api/orbit-mux')
485
+ assert status == 503 and 'ffmpeg' in body['error']
486
+ monkeypatch.setattr(web_ui, '_ffmpeg_path', lambda: '/usr/bin/ffmpeg')
487
+ status, body = get('/api/orbit-mux')
488
+ assert status == 404 and 'not streaming' in body['error']
489
+ monkeypatch.setattr(web_ui, '_orbit_ws_connected', True)
490
+ status, body = get('/api/orbit-mux')
491
+ assert status == 404 and 'audio' in body['error']
492
+ st = http_get_json(web_server + '/api/orbit-stream')
493
+ assert st['ffmpeg'] is True and st['mux'] is False and st['mux_url'].endswith('/api/orbit-mux')
@@ -25,6 +25,7 @@ import signal
25
25
  import socket
26
26
  import queue
27
27
  import secrets
28
+ import shutil
28
29
  import ssl
29
30
  import struct
30
31
  import subprocess
@@ -575,6 +576,14 @@ class _LiveContainerRelay:
575
576
  _orbit_audio_relay = _LiveContainerRelay()
576
577
 
577
578
 
579
+ def _ffmpeg_path():
580
+ """ffmpeg for the muxed feed (/api/orbit-mux): $WEED_FFMPEG, else on PATH, else None."""
581
+ p = os.environ.get('WEED_FFMPEG')
582
+ if p and os.access(p, os.X_OK):
583
+ return p
584
+ return shutil.which('ffmpeg')
585
+
586
+
578
587
  def _ws_unmask(data, mask_key):
579
588
  """Client->server WebSocket payloads arrive XOR-masked with a 4-byte
580
589
  key. Done as one big-int XOR rather than a per-byte Python loop:
@@ -1219,14 +1228,14 @@ class Handler(BaseHTTPRequestHandler):
1219
1228
 
1220
1229
  # GET endpoints a guest (stream token) may use; everything else under
1221
1230
  # /api/ is admin-only. The stream endpoints also take ?token=.
1222
- _GUEST_GET = ('/api/orbit-view', '/api/orbit-audio', '/api/orbit-stream', '/api/config', '/api/party', '/api/chat')
1231
+ _GUEST_GET = ('/api/orbit-view', '/api/orbit-audio', '/api/orbit-mux', '/api/orbit-stream', '/api/config', '/api/party', '/api/chat')
1223
1232
 
1224
1233
  def do_GET(self):
1225
1234
  parsed = urlparse(self.path)
1226
1235
  path, qs = parsed.path, parse_qs(parsed.query)
1227
1236
 
1228
1237
  if path.startswith('/api/'):
1229
- takes_query_token = path in ('/api/orbit-view', '/api/orbit-audio') or path.startswith('/api/stream/')
1238
+ takes_query_token = path in ('/api/orbit-view', '/api/orbit-audio', '/api/orbit-mux') or path.startswith('/api/stream/')
1230
1239
  q = qs if takes_query_token else None
1231
1240
  guest_ok = takes_query_token or path in self._GUEST_GET
1232
1241
  if not (self._guest_ok(q) if guest_ok else self._authorized(q)):
@@ -1322,6 +1331,8 @@ class Handler(BaseHTTPRequestHandler):
1322
1331
  return self._handle_orbit_view()
1323
1332
  if path == '/api/orbit-audio-ws':
1324
1333
  return self._handle_orbit_audio_websocket(qs)
1334
+ if path == '/api/orbit-mux':
1335
+ return self._handle_orbit_mux()
1325
1336
  if path == '/api/orbit-audio':
1326
1337
  return self._handle_orbit_audio()
1327
1338
  if path == '/api/orbit-stream':
@@ -2036,6 +2047,11 @@ class Handler(BaseHTTPRequestHandler):
2036
2047
  'audio_listeners': _orbit_audio_relay.subscriber_count(),
2037
2048
  'audio_url': f'{scheme}://{host}/api/orbit-audio{token_qs}',
2038
2049
  'audio_plain_url': f'{plain_base}/api/orbit-audio{token_qs}' if plain_base else None,
2050
+ # picture + audio as one Matroska stream (ffmpeg on the node, stream copy)
2051
+ 'ffmpeg': bool(_ffmpeg_path()),
2052
+ 'mux': bool(active and audio.get('on') and _ffmpeg_path()),
2053
+ 'mux_url': f'{scheme}://{host}/api/orbit-mux{token_qs}',
2054
+ 'mux_plain_url': f'{plain_base}/api/orbit-mux{token_qs}' if plain_base else None,
2039
2055
  })
2040
2056
 
2041
2057
  def _handle_orbit_view(self):
@@ -2276,6 +2292,103 @@ class Handler(BaseHTTPRequestHandler):
2276
2292
  finally:
2277
2293
  _orbit_audio_relay.unsubscribe(q)
2278
2294
 
2295
+ def _handle_orbit_mux(self):
2296
+ """Picture and audio as ONE live stream -- Matroska with the MJPEG
2297
+ frames and the Opus audio copied in as they are, no transcoding --
2298
+ for players that can't pair two URLs (Kodi, most TV apps). ffmpeg
2299
+ on the node does the muxing: each viewer gets its own process fed
2300
+ through two FIFOs, the JPEG frames from the picture fanout and the
2301
+ container bytes from the audio relay, and its stdout is this
2302
+ response. Timestamps: the picture is stamped by arrival time, the
2303
+ audio keeps its recorder's own clock, and ffmpeg starts both at
2304
+ zero, so they line up to within the recorder's blob interval.
2305
+ 503 with a plain message when there's no ffmpeg on the node, 404
2306
+ while nothing (or no audio) is streaming."""
2307
+ ffmpeg = _ffmpeg_path()
2308
+ if not ffmpeg:
2309
+ return self._json({'error': 'the node has no ffmpeg -- the picture+audio feed needs it '
2310
+ '(apt-get install ffmpeg on the node, or set WEED_FFMPEG to the binary)'}, status=503)
2311
+ with _orbit_ws_lock:
2312
+ active = _orbit_ws_connected
2313
+ with _orbit_audio_lock:
2314
+ on, mime = _orbit_audio['on'], _orbit_audio['mime']
2315
+ if not active:
2316
+ return self._json({'error': 'the visualizer is not streaming right now'}, status=404)
2317
+ if not on or not mime:
2318
+ return self._json({'error': 'the stream has no audio right now (the streamer\'s 🔊 toggle is off)'}, status=404)
2319
+ afmt = 'ogg' if 'ogg' in mime else 'webm'
2320
+ tmp = tempfile.mkdtemp(prefix='weed-mux-')
2321
+ vfifo, afifo = os.path.join(tmp, 'v.mjpeg'), os.path.join(tmp, 'a.' + afmt)
2322
+ os.mkfifo(vfifo); os.mkfifo(afifo)
2323
+ vq = queue.Queue(maxsize=4)
2324
+ with _orbit_subs_lock:
2325
+ _orbit_subscribers.add(vq)
2326
+ aq = _orbit_audio_relay.subscribe()
2327
+ stop = threading.Event()
2328
+
2329
+ def pump(q, path):
2330
+ # opening a FIFO for writing blocks until ffmpeg opens it to
2331
+ # read, which it does input by input, so each pump waits its turn
2332
+ try:
2333
+ with open(path, 'wb', buffering=0) as f:
2334
+ while not stop.is_set():
2335
+ try:
2336
+ item = q.get(timeout=1)
2337
+ except queue.Empty:
2338
+ continue
2339
+ if item is None:
2340
+ break
2341
+ f.write(item)
2342
+ except OSError:
2343
+ pass
2344
+ finally:
2345
+ stop.set()
2346
+
2347
+ cmd = [ffmpeg, '-nostdin', '-hide_banner', '-loglevel', 'error',
2348
+ '-use_wallclock_as_timestamps', '1', '-f', 'mjpeg', '-i', vfifo,
2349
+ '-f', afmt, '-i', afifo,
2350
+ '-map', '0:v:0', '-map', '1:a:0', '-c', 'copy',
2351
+ '-f', 'matroska', '-live', '1', '-cluster_time_limit', '500', '-cluster_size_limit', '65536',
2352
+ '-flush_packets', '1', 'pipe:1']
2353
+ proc = None
2354
+ try:
2355
+ proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL)
2356
+ for q, path in ((vq, vfifo), (aq, afifo)):
2357
+ threading.Thread(target=pump, args=(q, path), daemon=True).start()
2358
+ try:
2359
+ self.connection.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
2360
+ except OSError:
2361
+ pass
2362
+ self.send_response(200)
2363
+ self.send_header('Content-Type', 'video/x-matroska')
2364
+ self.send_header('Cache-Control', 'no-cache')
2365
+ self.send_header('Connection', 'close')
2366
+ self.end_headers()
2367
+ self.wfile.flush()
2368
+ while True:
2369
+ data = proc.stdout.read1(65536)
2370
+ if not data:
2371
+ break
2372
+ try:
2373
+ self.wfile.write(data)
2374
+ self.wfile.flush()
2375
+ except OSError:
2376
+ break
2377
+ except Exception:
2378
+ pass
2379
+ finally:
2380
+ stop.set()
2381
+ if proc is not None:
2382
+ try:
2383
+ proc.kill(); proc.wait(timeout=5)
2384
+ except Exception:
2385
+ pass
2386
+ with _orbit_subs_lock:
2387
+ _orbit_subscribers.discard(vq)
2388
+ _orbit_audio_relay.unsubscribe(aq)
2389
+ vq.put_nowait(None) if not vq.full() else None
2390
+ shutil.rmtree(tmp, ignore_errors=True)
2391
+
2279
2392
  def _handle_stream(self, job_id):
2280
2393
  """Serve an already-downloaded job's file with real HTTP range
2281
2394
  support, so a <video> tag can seek/scrub instead of just
@@ -2396,7 +2509,7 @@ class StreamOnlyHandler(Handler):
2396
2509
  it next to a --tls UI doesn't quietly reopen the whole control
2397
2510
  surface over plain HTTP. Auth, if on, applies exactly as on the main
2398
2511
  port (a player uses ?token=)."""
2399
- _ALLOWED = ('/api/orbit-view', '/api/orbit-audio', '/api/orbit-stream')
2512
+ _ALLOWED = ('/api/orbit-view', '/api/orbit-audio', '/api/orbit-mux', '/api/orbit-stream')
2400
2513
 
2401
2514
  def do_GET(self):
2402
2515
  path = urlparse(self.path).path
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: weed-cli
3
- Version: 2.6.2
3
+ Version: 2.7.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
@@ -56,6 +56,7 @@ output (not simulated) — see [Status](#status) for what's verified.
56
56
  - [Interactive shell](#interactive-shell)
57
57
  - [Web UI](#web-ui)
58
58
  - [Orbit Visualizer](#orbit-visualizer)
59
+ - [Kodi add-on](#kodi-add-on)
59
60
  - [Orbit Visualizer plugins](#orbit-visualizer-plugins)
60
61
  - [Docker](#docker)
61
62
  - [Core mechanisms](#core-mechanisms)
@@ -225,7 +226,7 @@ The player's 🌀 button opens the **Orbit Visualizer**: a canvas driven by
225
226
  the track's audio (Web Audio analyser) and a low-res sample of its
226
227
  picture. Eleven built-in modes — Tunnel, Bars, Mirror, Scope, Spiral,
227
228
  Pixels, ASCII (six character sets, natural or neon), Plasma, Kaleido,
228
- Particles, Freefall — plus the seventeen in `web/orbit_extras.js` (next
229
+ Particles, Freefall — plus the nineteen in `web/orbit_extras.js` (next
229
230
  section). Click the lit mode again for the plain video. Speed, React and
230
231
  Zoom retune every mode; every setting persists in the browser.
231
232
 
@@ -255,6 +256,11 @@ Zoom retune every mode; every setting persists in the browser.
255
256
  guest party page shows a 🔊 listen button; VLC takes both with
256
257
  `vlc --demux=mjpeg <view-url> --input-slave=<audio-url>`; OBS takes the
257
258
  audio URL as a second Media Source. Safari/iOS can't play WebM audio.
259
+ With ffmpeg on the node (the Docker image has it; otherwise
260
+ `$WEED_FFMPEG` or PATH), `/api/orbit-mux` is picture and audio as one
261
+ live Matroska stream, stream-copied, no transcoding -- for Kodi and
262
+ anything else that can't pair two URLs. Roku still can't: it wants
263
+ H.264, and this is MJPEG in a box.
258
264
  - **MIDI** (🎹): an AKAI MPK mini or any Web MIDI controller
259
265
  (Chrome/Edge/Firefox). Pads pick modes and fire actions, knobs turn
260
266
  the sliders or sweep through modes/transitions/character sets;
@@ -286,6 +292,36 @@ Zoom retune every mode; every setting persists in the browser.
286
292
  slider in the picker, and the pairing is remembered per track. The
287
293
  picker has a search box for big libraries.
288
294
 
295
+ ### Kodi add-on
296
+
297
+ `kodi/plugin.video.weed` is a Kodi add-on (Kodi 19 Matrix or later, any
298
+ platform) that puts the node on a TV without streaming anything: it
299
+ browses the node over the same web API the browser uses and plays the
300
+ downloaded files through Kodi's own player, which handles every codec.
301
+ Screens: **Downloads** (newest first, extensions dropped), **Playlists**
302
+ with play-all, **Tags** as folders, **Party** (the vote list, select to
303
+ +1), **Autopilot** (the node's weighted least-played pick), and the
304
+ **live Orbit feed** while the visualizer is streaming: picture and audio
305
+ together (`/api/orbit-mux`, needs ffmpeg on the node), or the picture or
306
+ the audio alone (the audio one lights up Kodi's own visualisers). Plays are counted on
307
+ the node like the browser's.
308
+
309
+ ```bash
310
+ make kodi # -> dist/plugin.video.weed-<ver>.zip
311
+ ```
312
+
313
+ On the Kodi box: Settings → Add-ons → *Unknown sources* on → *Install
314
+ from zip file* → the zip (copy it over, or share it with the node's
315
+ `/share`). Then open the add-on's settings and set the node URL, for
316
+ example `http://192.168.1.137:8080`, and the token if the node runs with
317
+ `--auth-token`. The add-on hands Kodi the `/api/stream/<job_id>` URLs
318
+ with the token in the query, so seeking and resume work as for any file.
319
+ An https node works too: the add-on accepts the node's self-signed
320
+ certificate for its API calls (a setting, on by default), and sends the
321
+ player to the plain stream port the node advertises
322
+ (`--stream-plain-port`), falling back to https with Kodi's own
323
+ `verifypeer=false` URL option when there is no plain port.
324
+
289
325
  ### Orbit Visualizer plugins
290
326
 
291
327
  The visualizer's modes and its Fade transitions are both open registries:
@@ -299,10 +335,12 @@ mode selector; a registered transition gets a Fade option and a turn in
299
335
  taking the draw loop down. `web/orbit_extras.js` (loaded by default) is
300
336
  built entirely on that API and adds Halftone, Lava, Terrain, Rain,
301
337
  Lissajous, Vinyl 33, Cube, VHS, Win95, Joy Division, Spectrogram, Stained
302
- glass, Fireworks, Screensaver, Slit-scan, Skyline and Globe, plus the
303
- Melt, Dissolve, Iris, Shatter, Wave, Spin, Zoom blur, RGB split, VHS,
304
- Win95, Blinds, Flip tiles, CRT off, Droplet, Blur, Slide and Flash
305
- transitions (Globe's coastlines are Natural Earth's public-domain 110m
338
+ glass, Fireworks, Screensaver, Slit-scan, Skyline, Globe, Starfield and
339
+ Spectrum 3D, plus the Melt, Dissolve, Iris, Shatter, Wave, Spin, Zoom
340
+ blur, RGB split, VHS, Win95, Blinds, Flip tiles, CRT off, Droplet, Blur,
341
+ Slide, Flash, Desktop cube, Carousel and Doors transitions (the 3D ones
342
+ draw perspective faces as runs of affine strips, since Canvas 2D has no
343
+ projection of its own; Globe's coastlines are Natural Earth's public-domain 110m
306
344
  land set, simplified into `web/land.json`); `web/orbit_plugin_example.js`
307
345
  is a minimal one to copy.
308
346
 
@@ -523,7 +561,10 @@ just designed:
523
561
  - Kademlia DHT discovery, survives the announcing node going offline
524
562
  - Local web UI with live progress, QR onboarding, HTTP range streaming,
525
563
  optional two-tier token auth, and the guest party page with voting
526
- - Orbit Visualizer: 28 modes and 23 transitions behind a plugin API,
564
+ - Kodi add-on (`kodi/plugin.video.weed`): downloads, playlists, tags,
565
+ party votes, Autopilot pick and the live feeds on a TV through Kodi's
566
+ own player, 9 tests against a real node with Kodi stubbed.
567
+ - Orbit Visualizer: 30 modes and 26 transitions behind a plugin API,
527
568
  MJPEG network stream (VLC/Roku), Web MIDI control with keymap files,
528
569
  video swap — 44 Playwright tests against real Chromium (`tests/e2e/`)
529
570
  - Containerized node (`Dockerfile.node`, `docker-compose.node.yml`),
@@ -13,6 +13,7 @@ weed.py
13
13
  tests/test_dht.py
14
14
  tests/test_discovery_relay.py
15
15
  tests/test_host_live_reload.py
16
+ tests/test_kodi_addon.py
16
17
  tests/test_node_manifest.py
17
18
  tests/test_orbit_audio.py
18
19
  tests/test_orbit_ws.py
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