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.
- {weed_cli-2.6.2 → weed_cli-2.7.0}/PKG-INFO +48 -7
- {weed_cli-2.6.2 → weed_cli-2.7.0}/README.md +47 -6
- {weed_cli-2.6.2 → weed_cli-2.7.0}/pyproject.toml +1 -1
- weed_cli-2.7.0/tests/test_kodi_addon.py +267 -0
- {weed_cli-2.6.2 → weed_cli-2.7.0}/tests/test_orbit_audio.py +22 -0
- {weed_cli-2.6.2 → weed_cli-2.7.0}/web_ui.py +116 -3
- {weed_cli-2.6.2 → weed_cli-2.7.0}/weed_cli.egg-info/PKG-INFO +48 -7
- {weed_cli-2.6.2 → weed_cli-2.7.0}/weed_cli.egg-info/SOURCES.txt +1 -0
- {weed_cli-2.6.2 → weed_cli-2.7.0}/LICENSE +0 -0
- {weed_cli-2.6.2 → weed_cli-2.7.0}/dht.py +0 -0
- {weed_cli-2.6.2 → weed_cli-2.7.0}/discovery_relay.py +0 -0
- {weed_cli-2.6.2 → weed_cli-2.7.0}/lightning_settle.py +0 -0
- {weed_cli-2.6.2 → weed_cli-2.7.0}/node.py +0 -0
- {weed_cli-2.6.2 → weed_cli-2.7.0}/poc_reputation.py +0 -0
- {weed_cli-2.6.2 → weed_cli-2.7.0}/setup.cfg +0 -0
- {weed_cli-2.6.2 → weed_cli-2.7.0}/shell.py +0 -0
- {weed_cli-2.6.2 → weed_cli-2.7.0}/tests/test_dht.py +0 -0
- {weed_cli-2.6.2 → weed_cli-2.7.0}/tests/test_discovery_relay.py +0 -0
- {weed_cli-2.6.2 → weed_cli-2.7.0}/tests/test_host_live_reload.py +0 -0
- {weed_cli-2.6.2 → weed_cli-2.7.0}/tests/test_node_manifest.py +0 -0
- {weed_cli-2.6.2 → weed_cli-2.7.0}/tests/test_orbit_ws.py +0 -0
- {weed_cli-2.6.2 → weed_cli-2.7.0}/tests/test_timing_challenge.py +0 -0
- {weed_cli-2.6.2 → weed_cli-2.7.0}/tests/test_tunnel_failover.py +0 -0
- {weed_cli-2.6.2 → weed_cli-2.7.0}/tests/test_web_ui_api.py +0 -0
- {weed_cli-2.6.2 → weed_cli-2.7.0}/tests/testutil.py +0 -0
- {weed_cli-2.6.2 → weed_cli-2.7.0}/tunnel_relay.py +0 -0
- {weed_cli-2.6.2 → weed_cli-2.7.0}/weed.py +0 -0
- {weed_cli-2.6.2 → weed_cli-2.7.0}/weed_cli.egg-info/dependency_links.txt +0 -0
- {weed_cli-2.6.2 → weed_cli-2.7.0}/weed_cli.egg-info/entry_points.txt +0 -0
- {weed_cli-2.6.2 → weed_cli-2.7.0}/weed_cli.egg-info/requires.txt +0 -0
- {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.
|
|
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
|
|
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
|
|
303
|
-
Melt, Dissolve, Iris, Shatter, Wave, Spin, Zoom
|
|
304
|
-
Win95, Blinds, Flip tiles, CRT off, Droplet, Blur,
|
|
305
|
-
|
|
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
|
-
-
|
|
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
|
|
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
|
|
271
|
-
Melt, Dissolve, Iris, Shatter, Wave, Spin, Zoom
|
|
272
|
-
Win95, Blinds, Flip tiles, CRT off, Droplet, Blur,
|
|
273
|
-
|
|
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
|
-
-
|
|
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.
|
|
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.
|
|
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
|
|
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
|
|
303
|
-
Melt, Dissolve, Iris, Shatter, Wave, Spin, Zoom
|
|
304
|
-
Win95, Blinds, Flip tiles, CRT off, Droplet, Blur,
|
|
305
|
-
|
|
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
|
-
-
|
|
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`),
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|