weed-cli 2.6.2__tar.gz → 2.7.1__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.1}/PKG-INFO +55 -7
  2. {weed_cli-2.6.2 → weed_cli-2.7.1}/README.md +54 -6
  3. {weed_cli-2.6.2 → weed_cli-2.7.1}/pyproject.toml +1 -1
  4. weed_cli-2.7.1/tests/test_kodi_addon.py +267 -0
  5. {weed_cli-2.6.2 → weed_cli-2.7.1}/tests/test_orbit_audio.py +22 -0
  6. {weed_cli-2.6.2 → weed_cli-2.7.1}/web_ui.py +116 -3
  7. {weed_cli-2.6.2 → weed_cli-2.7.1}/weed_cli.egg-info/PKG-INFO +55 -7
  8. {weed_cli-2.6.2 → weed_cli-2.7.1}/weed_cli.egg-info/SOURCES.txt +1 -0
  9. {weed_cli-2.6.2 → weed_cli-2.7.1}/LICENSE +0 -0
  10. {weed_cli-2.6.2 → weed_cli-2.7.1}/dht.py +0 -0
  11. {weed_cli-2.6.2 → weed_cli-2.7.1}/discovery_relay.py +0 -0
  12. {weed_cli-2.6.2 → weed_cli-2.7.1}/lightning_settle.py +0 -0
  13. {weed_cli-2.6.2 → weed_cli-2.7.1}/node.py +0 -0
  14. {weed_cli-2.6.2 → weed_cli-2.7.1}/poc_reputation.py +0 -0
  15. {weed_cli-2.6.2 → weed_cli-2.7.1}/setup.cfg +0 -0
  16. {weed_cli-2.6.2 → weed_cli-2.7.1}/shell.py +0 -0
  17. {weed_cli-2.6.2 → weed_cli-2.7.1}/tests/test_dht.py +0 -0
  18. {weed_cli-2.6.2 → weed_cli-2.7.1}/tests/test_discovery_relay.py +0 -0
  19. {weed_cli-2.6.2 → weed_cli-2.7.1}/tests/test_host_live_reload.py +0 -0
  20. {weed_cli-2.6.2 → weed_cli-2.7.1}/tests/test_node_manifest.py +0 -0
  21. {weed_cli-2.6.2 → weed_cli-2.7.1}/tests/test_orbit_ws.py +0 -0
  22. {weed_cli-2.6.2 → weed_cli-2.7.1}/tests/test_timing_challenge.py +0 -0
  23. {weed_cli-2.6.2 → weed_cli-2.7.1}/tests/test_tunnel_failover.py +0 -0
  24. {weed_cli-2.6.2 → weed_cli-2.7.1}/tests/test_web_ui_api.py +0 -0
  25. {weed_cli-2.6.2 → weed_cli-2.7.1}/tests/testutil.py +0 -0
  26. {weed_cli-2.6.2 → weed_cli-2.7.1}/tunnel_relay.py +0 -0
  27. {weed_cli-2.6.2 → weed_cli-2.7.1}/weed.py +0 -0
  28. {weed_cli-2.6.2 → weed_cli-2.7.1}/weed_cli.egg-info/dependency_links.txt +0 -0
  29. {weed_cli-2.6.2 → weed_cli-2.7.1}/weed_cli.egg-info/entry_points.txt +0 -0
  30. {weed_cli-2.6.2 → weed_cli-2.7.1}/weed_cli.egg-info/requires.txt +0 -0
  31. {weed_cli-2.6.2 → weed_cli-2.7.1}/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.1
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;
@@ -279,6 +285,13 @@ Zoom retune every mode; every setting persists in the browser.
279
285
  tagged" pick in the pool row limits the draw to one tag. The ☑
280
286
  button beside the toggle unfolds a checkbox per mode: untick the ones
281
287
  Autopilot should leave alone (they stay a click or a pad away).
288
+ - **Crossfader** (⇆ in the transport bar, or the MIDI panel's Crossfader
289
+ row): cues the next track -- the queue's next, else Autopilot's pick --
290
+ on a second hidden deck and fades to it, equal-power on the sound and
291
+ opacity on the picture; the far end of the fader makes it the track
292
+ with no gap. The fader stays where it landed and the next fade runs
293
+ the other way, as on a DJ mixer, so a knob left at an end never
294
+ commits by accident. A track ending mid-cue jumps to the cued one.
282
295
  - **Video swap** (⇄ on the player): another download's picture stands
283
296
  in for the current track's — for an mp3 or a static-image video, so
284
297
  the visuals have real footage. Audio stays with the track; the
@@ -286,6 +299,36 @@ Zoom retune every mode; every setting persists in the browser.
286
299
  slider in the picker, and the pairing is remembered per track. The
287
300
  picker has a search box for big libraries.
288
301
 
302
+ ### Kodi add-on
303
+
304
+ `kodi/plugin.video.weed` is a Kodi add-on (Kodi 19 Matrix or later, any
305
+ platform) that puts the node on a TV without streaming anything: it
306
+ browses the node over the same web API the browser uses and plays the
307
+ downloaded files through Kodi's own player, which handles every codec.
308
+ Screens: **Downloads** (newest first, extensions dropped), **Playlists**
309
+ with play-all, **Tags** as folders, **Party** (the vote list, select to
310
+ +1), **Autopilot** (the node's weighted least-played pick), and the
311
+ **live Orbit feed** while the visualizer is streaming: picture and audio
312
+ together (`/api/orbit-mux`, needs ffmpeg on the node), or the picture or
313
+ the audio alone (the audio one lights up Kodi's own visualisers). Plays are counted on
314
+ the node like the browser's.
315
+
316
+ ```bash
317
+ make kodi # -> dist/plugin.video.weed-<ver>.zip
318
+ ```
319
+
320
+ On the Kodi box: Settings → Add-ons → *Unknown sources* on → *Install
321
+ from zip file* → the zip (copy it over, or share it with the node's
322
+ `/share`). Then open the add-on's settings and set the node URL, for
323
+ example `http://192.168.1.137:8080`, and the token if the node runs with
324
+ `--auth-token`. The add-on hands Kodi the `/api/stream/<job_id>` URLs
325
+ with the token in the query, so seeking and resume work as for any file.
326
+ An https node works too: the add-on accepts the node's self-signed
327
+ certificate for its API calls (a setting, on by default), and sends the
328
+ player to the plain stream port the node advertises
329
+ (`--stream-plain-port`), falling back to https with Kodi's own
330
+ `verifypeer=false` URL option when there is no plain port.
331
+
289
332
  ### Orbit Visualizer plugins
290
333
 
291
334
  The visualizer's modes and its Fade transitions are both open registries:
@@ -299,10 +342,12 @@ mode selector; a registered transition gets a Fade option and a turn in
299
342
  taking the draw loop down. `web/orbit_extras.js` (loaded by default) is
300
343
  built entirely on that API and adds Halftone, Lava, Terrain, Rain,
301
344
  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
345
+ glass, Fireworks, Screensaver, Slit-scan, Skyline, Globe, Starfield,
346
+ Spectrum 3D, Brain and Arcade, plus the Melt, Dissolve, Iris, Shatter, Wave, Spin, Zoom
347
+ blur, RGB split, VHS, Win95, Blinds, Flip tiles, CRT off, Droplet, Blur,
348
+ Slide, Flash, Desktop cube, Carousel and Doors transitions (the 3D ones
349
+ draw perspective faces as runs of affine strips, since Canvas 2D has no
350
+ projection of its own; Globe's coastlines are Natural Earth's public-domain 110m
306
351
  land set, simplified into `web/land.json`); `web/orbit_plugin_example.js`
307
352
  is a minimal one to copy.
308
353
 
@@ -523,7 +568,10 @@ just designed:
523
568
  - Kademlia DHT discovery, survives the announcing node going offline
524
569
  - Local web UI with live progress, QR onboarding, HTTP range streaming,
525
570
  optional two-tier token auth, and the guest party page with voting
526
- - Orbit Visualizer: 28 modes and 23 transitions behind a plugin API,
571
+ - Kodi add-on (`kodi/plugin.video.weed`): downloads, playlists, tags,
572
+ party votes, Autopilot pick and the live feeds on a TV through Kodi's
573
+ own player, 9 tests against a real node with Kodi stubbed.
574
+ - Orbit Visualizer: 30 modes and 26 transitions behind a plugin API,
527
575
  MJPEG network stream (VLC/Roku), Web MIDI control with keymap files,
528
576
  video swap — 44 Playwright tests against real Chromium (`tests/e2e/`)
529
577
  - 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;
@@ -247,6 +253,13 @@ Zoom retune every mode; every setting persists in the browser.
247
253
  tagged" pick in the pool row limits the draw to one tag. The ☑
248
254
  button beside the toggle unfolds a checkbox per mode: untick the ones
249
255
  Autopilot should leave alone (they stay a click or a pad away).
256
+ - **Crossfader** (⇆ in the transport bar, or the MIDI panel's Crossfader
257
+ row): cues the next track -- the queue's next, else Autopilot's pick --
258
+ on a second hidden deck and fades to it, equal-power on the sound and
259
+ opacity on the picture; the far end of the fader makes it the track
260
+ with no gap. The fader stays where it landed and the next fade runs
261
+ the other way, as on a DJ mixer, so a knob left at an end never
262
+ commits by accident. A track ending mid-cue jumps to the cued one.
250
263
  - **Video swap** (⇄ on the player): another download's picture stands
251
264
  in for the current track's — for an mp3 or a static-image video, so
252
265
  the visuals have real footage. Audio stays with the track; the
@@ -254,6 +267,36 @@ Zoom retune every mode; every setting persists in the browser.
254
267
  slider in the picker, and the pairing is remembered per track. The
255
268
  picker has a search box for big libraries.
256
269
 
270
+ ### Kodi add-on
271
+
272
+ `kodi/plugin.video.weed` is a Kodi add-on (Kodi 19 Matrix or later, any
273
+ platform) that puts the node on a TV without streaming anything: it
274
+ browses the node over the same web API the browser uses and plays the
275
+ downloaded files through Kodi's own player, which handles every codec.
276
+ Screens: **Downloads** (newest first, extensions dropped), **Playlists**
277
+ with play-all, **Tags** as folders, **Party** (the vote list, select to
278
+ +1), **Autopilot** (the node's weighted least-played pick), and the
279
+ **live Orbit feed** while the visualizer is streaming: picture and audio
280
+ together (`/api/orbit-mux`, needs ffmpeg on the node), or the picture or
281
+ the audio alone (the audio one lights up Kodi's own visualisers). Plays are counted on
282
+ the node like the browser's.
283
+
284
+ ```bash
285
+ make kodi # -> dist/plugin.video.weed-<ver>.zip
286
+ ```
287
+
288
+ On the Kodi box: Settings → Add-ons → *Unknown sources* on → *Install
289
+ from zip file* → the zip (copy it over, or share it with the node's
290
+ `/share`). Then open the add-on's settings and set the node URL, for
291
+ example `http://192.168.1.137:8080`, and the token if the node runs with
292
+ `--auth-token`. The add-on hands Kodi the `/api/stream/<job_id>` URLs
293
+ with the token in the query, so seeking and resume work as for any file.
294
+ An https node works too: the add-on accepts the node's self-signed
295
+ certificate for its API calls (a setting, on by default), and sends the
296
+ player to the plain stream port the node advertises
297
+ (`--stream-plain-port`), falling back to https with Kodi's own
298
+ `verifypeer=false` URL option when there is no plain port.
299
+
257
300
  ### Orbit Visualizer plugins
258
301
 
259
302
  The visualizer's modes and its Fade transitions are both open registries:
@@ -267,10 +310,12 @@ mode selector; a registered transition gets a Fade option and a turn in
267
310
  taking the draw loop down. `web/orbit_extras.js` (loaded by default) is
268
311
  built entirely on that API and adds Halftone, Lava, Terrain, Rain,
269
312
  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
313
+ glass, Fireworks, Screensaver, Slit-scan, Skyline, Globe, Starfield,
314
+ Spectrum 3D, Brain and Arcade, plus the Melt, Dissolve, Iris, Shatter, Wave, Spin, Zoom
315
+ blur, RGB split, VHS, Win95, Blinds, Flip tiles, CRT off, Droplet, Blur,
316
+ Slide, Flash, Desktop cube, Carousel and Doors transitions (the 3D ones
317
+ draw perspective faces as runs of affine strips, since Canvas 2D has no
318
+ projection of its own; Globe's coastlines are Natural Earth's public-domain 110m
274
319
  land set, simplified into `web/land.json`); `web/orbit_plugin_example.js`
275
320
  is a minimal one to copy.
276
321
 
@@ -491,7 +536,10 @@ just designed:
491
536
  - Kademlia DHT discovery, survives the announcing node going offline
492
537
  - Local web UI with live progress, QR onboarding, HTTP range streaming,
493
538
  optional two-tier token auth, and the guest party page with voting
494
- - Orbit Visualizer: 28 modes and 23 transitions behind a plugin API,
539
+ - Kodi add-on (`kodi/plugin.video.weed`): downloads, playlists, tags,
540
+ party votes, Autopilot pick and the live feeds on a TV through Kodi's
541
+ own player, 9 tests against a real node with Kodi stubbed.
542
+ - Orbit Visualizer: 30 modes and 26 transitions behind a plugin API,
495
543
  MJPEG network stream (VLC/Roku), Web MIDI control with keymap files,
496
544
  video swap — 44 Playwright tests against real Chromium (`tests/e2e/`)
497
545
  - 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.1"
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.1
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;
@@ -279,6 +285,13 @@ Zoom retune every mode; every setting persists in the browser.
279
285
  tagged" pick in the pool row limits the draw to one tag. The ☑
280
286
  button beside the toggle unfolds a checkbox per mode: untick the ones
281
287
  Autopilot should leave alone (they stay a click or a pad away).
288
+ - **Crossfader** (⇆ in the transport bar, or the MIDI panel's Crossfader
289
+ row): cues the next track -- the queue's next, else Autopilot's pick --
290
+ on a second hidden deck and fades to it, equal-power on the sound and
291
+ opacity on the picture; the far end of the fader makes it the track
292
+ with no gap. The fader stays where it landed and the next fade runs
293
+ the other way, as on a DJ mixer, so a knob left at an end never
294
+ commits by accident. A track ending mid-cue jumps to the cued one.
282
295
  - **Video swap** (⇄ on the player): another download's picture stands
283
296
  in for the current track's — for an mp3 or a static-image video, so
284
297
  the visuals have real footage. Audio stays with the track; the
@@ -286,6 +299,36 @@ Zoom retune every mode; every setting persists in the browser.
286
299
  slider in the picker, and the pairing is remembered per track. The
287
300
  picker has a search box for big libraries.
288
301
 
302
+ ### Kodi add-on
303
+
304
+ `kodi/plugin.video.weed` is a Kodi add-on (Kodi 19 Matrix or later, any
305
+ platform) that puts the node on a TV without streaming anything: it
306
+ browses the node over the same web API the browser uses and plays the
307
+ downloaded files through Kodi's own player, which handles every codec.
308
+ Screens: **Downloads** (newest first, extensions dropped), **Playlists**
309
+ with play-all, **Tags** as folders, **Party** (the vote list, select to
310
+ +1), **Autopilot** (the node's weighted least-played pick), and the
311
+ **live Orbit feed** while the visualizer is streaming: picture and audio
312
+ together (`/api/orbit-mux`, needs ffmpeg on the node), or the picture or
313
+ the audio alone (the audio one lights up Kodi's own visualisers). Plays are counted on
314
+ the node like the browser's.
315
+
316
+ ```bash
317
+ make kodi # -> dist/plugin.video.weed-<ver>.zip
318
+ ```
319
+
320
+ On the Kodi box: Settings → Add-ons → *Unknown sources* on → *Install
321
+ from zip file* → the zip (copy it over, or share it with the node's
322
+ `/share`). Then open the add-on's settings and set the node URL, for
323
+ example `http://192.168.1.137:8080`, and the token if the node runs with
324
+ `--auth-token`. The add-on hands Kodi the `/api/stream/<job_id>` URLs
325
+ with the token in the query, so seeking and resume work as for any file.
326
+ An https node works too: the add-on accepts the node's self-signed
327
+ certificate for its API calls (a setting, on by default), and sends the
328
+ player to the plain stream port the node advertises
329
+ (`--stream-plain-port`), falling back to https with Kodi's own
330
+ `verifypeer=false` URL option when there is no plain port.
331
+
289
332
  ### Orbit Visualizer plugins
290
333
 
291
334
  The visualizer's modes and its Fade transitions are both open registries:
@@ -299,10 +342,12 @@ mode selector; a registered transition gets a Fade option and a turn in
299
342
  taking the draw loop down. `web/orbit_extras.js` (loaded by default) is
300
343
  built entirely on that API and adds Halftone, Lava, Terrain, Rain,
301
344
  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
345
+ glass, Fireworks, Screensaver, Slit-scan, Skyline, Globe, Starfield,
346
+ Spectrum 3D, Brain and Arcade, plus the Melt, Dissolve, Iris, Shatter, Wave, Spin, Zoom
347
+ blur, RGB split, VHS, Win95, Blinds, Flip tiles, CRT off, Droplet, Blur,
348
+ Slide, Flash, Desktop cube, Carousel and Doors transitions (the 3D ones
349
+ draw perspective faces as runs of affine strips, since Canvas 2D has no
350
+ projection of its own; Globe's coastlines are Natural Earth's public-domain 110m
306
351
  land set, simplified into `web/land.json`); `web/orbit_plugin_example.js`
307
352
  is a minimal one to copy.
308
353
 
@@ -523,7 +568,10 @@ just designed:
523
568
  - Kademlia DHT discovery, survives the announcing node going offline
524
569
  - Local web UI with live progress, QR onboarding, HTTP range streaming,
525
570
  optional two-tier token auth, and the guest party page with voting
526
- - Orbit Visualizer: 28 modes and 23 transitions behind a plugin API,
571
+ - Kodi add-on (`kodi/plugin.video.weed`): downloads, playlists, tags,
572
+ party votes, Autopilot pick and the live feeds on a TV through Kodi's
573
+ own player, 9 tests against a real node with Kodi stubbed.
574
+ - Orbit Visualizer: 30 modes and 26 transitions behind a plugin API,
527
575
  MJPEG network stream (VLC/Roku), Web MIDI control with keymap files,
528
576
  video swap — 44 Playwright tests against real Chromium (`tests/e2e/`)
529
577
  - 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