lanstreamer-sdk 1.0.0__py3-none-any.whl
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.
- lanstreamer_sdk/__init__.py +73 -0
- lanstreamer_sdk/__main__.py +5 -0
- lanstreamer_sdk/manifest.py +76 -0
- lanstreamer_sdk/plugin.py +260 -0
- lanstreamer_sdk/scaffold.py +114 -0
- lanstreamer_sdk/testing.py +56 -0
- lanstreamer_sdk/tools.py +112 -0
- lanstreamer_sdk-1.0.0.dist-info/METADATA +24 -0
- lanstreamer_sdk-1.0.0.dist-info/RECORD +10 -0
- lanstreamer_sdk-1.0.0.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""lan-streamer plugin SDK – the only package an input plugin depends on.
|
|
2
|
+
|
|
3
|
+
A plugin is a folder (or .zip) with a ``plugin.toml`` and a Python module:
|
|
4
|
+
|
|
5
|
+
# plugin.toml
|
|
6
|
+
id = "my-input"
|
|
7
|
+
name = "My input"
|
|
8
|
+
version = "1.0.0"
|
|
9
|
+
api_version = 1
|
|
10
|
+
entry = "my_input:Plugin"
|
|
11
|
+
|
|
12
|
+
# my_input.py
|
|
13
|
+
from lanstreamer_sdk import InputPlugin, UriSource
|
|
14
|
+
from pydantic import BaseModel
|
|
15
|
+
|
|
16
|
+
class Settings(BaseModel):
|
|
17
|
+
url: str
|
|
18
|
+
|
|
19
|
+
class Plugin(InputPlugin[Settings]):
|
|
20
|
+
Settings = Settings
|
|
21
|
+
|
|
22
|
+
async def is_active(self) -> bool:
|
|
23
|
+
return True
|
|
24
|
+
|
|
25
|
+
async def get_source(self):
|
|
26
|
+
return UriSource(self.settings.url)
|
|
27
|
+
|
|
28
|
+
Every plugin instance runs in its own process (see docs/architecture.md).
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
from lanstreamer_sdk.manifest import Manifest, ManifestError, load_manifest
|
|
32
|
+
from lanstreamer_sdk.plugin import (
|
|
33
|
+
API_VERSION,
|
|
34
|
+
CardSource,
|
|
35
|
+
FileSource,
|
|
36
|
+
ImageSource,
|
|
37
|
+
InputPlugin,
|
|
38
|
+
MediaSource,
|
|
39
|
+
Metadata,
|
|
40
|
+
NoSettings,
|
|
41
|
+
PlaybackEvent,
|
|
42
|
+
PluginContext,
|
|
43
|
+
Rejected,
|
|
44
|
+
Store,
|
|
45
|
+
TestSource,
|
|
46
|
+
UriSource,
|
|
47
|
+
Viewer,
|
|
48
|
+
ViewerItem,
|
|
49
|
+
source_to_dict,
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
__all__ = [
|
|
53
|
+
"API_VERSION",
|
|
54
|
+
"CardSource",
|
|
55
|
+
"FileSource",
|
|
56
|
+
"ImageSource",
|
|
57
|
+
"InputPlugin",
|
|
58
|
+
"Manifest",
|
|
59
|
+
"ManifestError",
|
|
60
|
+
"MediaSource",
|
|
61
|
+
"Metadata",
|
|
62
|
+
"NoSettings",
|
|
63
|
+
"PlaybackEvent",
|
|
64
|
+
"PluginContext",
|
|
65
|
+
"Rejected",
|
|
66
|
+
"Store",
|
|
67
|
+
"TestSource",
|
|
68
|
+
"UriSource",
|
|
69
|
+
"Viewer",
|
|
70
|
+
"ViewerItem",
|
|
71
|
+
"load_manifest",
|
|
72
|
+
"source_to_dict",
|
|
73
|
+
]
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""plugin.toml: what a plugin is and whether this lan-streamer can run it (F-PLG-04)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
import tomllib
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from lanstreamer_sdk.plugin import API_VERSION
|
|
11
|
+
|
|
12
|
+
ID_PATTERN = re.compile(r"^[a-z0-9][a-z0-9-]{1,48}$")
|
|
13
|
+
_ENTRY = re.compile(r"^[A-Za-z_][\w.]*:[A-Za-z_]\w*$")
|
|
14
|
+
# the plugins inside lan-streamer; an upload with one of these ids is refused
|
|
15
|
+
BUILTIN_IDS = frozenset({"ingest", "playlist", "standby", "submissions", "test-pattern", "youtube"})
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class ManifestError(ValueError):
|
|
19
|
+
pass
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def check_id_is_free(plugin_id: str) -> None:
|
|
23
|
+
if plugin_id in BUILTIN_IDS:
|
|
24
|
+
raise ManifestError(f"plugin id '{plugin_id}' belongs to a built-in plugin, choose another")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass(frozen=True)
|
|
28
|
+
class Manifest:
|
|
29
|
+
id: str
|
|
30
|
+
name: str
|
|
31
|
+
version: str
|
|
32
|
+
api_version: int
|
|
33
|
+
entry: str # "module:Class"
|
|
34
|
+
description: str = ""
|
|
35
|
+
requirements: list[str] = field(default_factory=list)
|
|
36
|
+
path: Path | None = None # folder that holds plugin.toml
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def load_manifest(folder: Path) -> Manifest:
|
|
40
|
+
"""Reads and checks folder/plugin.toml. Raises ManifestError with a clear message."""
|
|
41
|
+
file = folder / "plugin.toml"
|
|
42
|
+
if not file.is_file():
|
|
43
|
+
raise ManifestError("plugin.toml is missing")
|
|
44
|
+
try:
|
|
45
|
+
data = tomllib.loads(file.read_text(encoding="utf-8"))
|
|
46
|
+
except tomllib.TOMLDecodeError as exc:
|
|
47
|
+
raise ManifestError(f"plugin.toml is not valid TOML: {exc}") from exc
|
|
48
|
+
|
|
49
|
+
for key in ("id", "name", "version", "api_version", "entry"):
|
|
50
|
+
if key not in data:
|
|
51
|
+
raise ManifestError(f"plugin.toml: '{key}' is missing")
|
|
52
|
+
if not ID_PATTERN.match(str(data["id"])):
|
|
53
|
+
raise ManifestError("plugin.toml: 'id' must be 2–49 characters: a-z, 0-9 and '-'")
|
|
54
|
+
if not _ENTRY.match(str(data["entry"])):
|
|
55
|
+
raise ManifestError("plugin.toml: 'entry' must look like 'module:ClassName'")
|
|
56
|
+
if not isinstance(data["api_version"], int):
|
|
57
|
+
raise ManifestError("plugin.toml: 'api_version' must be a whole number")
|
|
58
|
+
if data["api_version"] != API_VERSION:
|
|
59
|
+
raise ManifestError(
|
|
60
|
+
f"plugin needs plugin API {data['api_version']}, "
|
|
61
|
+
f"this lan-streamer provides API {API_VERSION}"
|
|
62
|
+
)
|
|
63
|
+
requirements = data.get("requirements", [])
|
|
64
|
+
if not isinstance(requirements, list) or not all(isinstance(r, str) for r in requirements):
|
|
65
|
+
raise ManifestError("plugin.toml: 'requirements' must be a list of strings")
|
|
66
|
+
|
|
67
|
+
return Manifest(
|
|
68
|
+
id=data["id"],
|
|
69
|
+
name=str(data["name"]),
|
|
70
|
+
version=str(data["version"]),
|
|
71
|
+
api_version=data["api_version"],
|
|
72
|
+
entry=data["entry"],
|
|
73
|
+
description=str(data.get("description", "")),
|
|
74
|
+
requirements=requirements,
|
|
75
|
+
path=folder,
|
|
76
|
+
)
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
"""The input plugin API, version 1."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import json
|
|
7
|
+
import logging
|
|
8
|
+
from collections.abc import AsyncIterator, Callable
|
|
9
|
+
from dataclasses import asdict, dataclass, field
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any, ClassVar, Literal
|
|
12
|
+
|
|
13
|
+
from pydantic import BaseModel
|
|
14
|
+
|
|
15
|
+
API_VERSION = 1
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
# --- What a plugin can play ---------------------------------------------------------
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass(frozen=True)
|
|
22
|
+
class TestSource:
|
|
23
|
+
"""A generated test picture with a tone."""
|
|
24
|
+
|
|
25
|
+
__test__ = False # not a pytest test class
|
|
26
|
+
|
|
27
|
+
pattern: str = "smpte" # any GStreamer videotestsrc pattern
|
|
28
|
+
freq: int = 440
|
|
29
|
+
kind: Literal["test"] = "test"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass(frozen=True)
|
|
33
|
+
class UriSource:
|
|
34
|
+
"""Anything GStreamer can open by URI: http(s) HLS/MP4, rtsp, srt, file.
|
|
35
|
+
|
|
36
|
+
audio_uri: the sound from a second URI, when picture and sound come
|
|
37
|
+
separately (YouTube in HD); uri then gives only the picture.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
uri: str
|
|
41
|
+
audio_uri: str = ""
|
|
42
|
+
kind: Literal["uri"] = "uri"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass(frozen=True)
|
|
46
|
+
class FileSource:
|
|
47
|
+
"""A local media file.
|
|
48
|
+
|
|
49
|
+
cut: switch to it without crossfade, so nothing of its start is lost
|
|
50
|
+
(e.g. a video that follows an announcement card).
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
path: str
|
|
54
|
+
cut: bool = False
|
|
55
|
+
kind: Literal["file"] = "file"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@dataclass(frozen=True)
|
|
59
|
+
class ImageSource:
|
|
60
|
+
"""A still picture (PNG or JPEG) with silence – e.g. a standby slate.
|
|
61
|
+
|
|
62
|
+
It never ends by itself; an input that shows images decides how long
|
|
63
|
+
(e.g. the playlist's image duration).
|
|
64
|
+
"""
|
|
65
|
+
|
|
66
|
+
path: str
|
|
67
|
+
kind: Literal["image"] = "image"
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@dataclass(frozen=True)
|
|
71
|
+
class CardSource:
|
|
72
|
+
"""A dark card with text and silence – e.g. "Up next" / <title> / "by Anna".
|
|
73
|
+
|
|
74
|
+
Like an image it never ends by itself.
|
|
75
|
+
"""
|
|
76
|
+
|
|
77
|
+
title: str
|
|
78
|
+
heading: str = "" # small, above the title
|
|
79
|
+
subtitle: str = "" # below the title
|
|
80
|
+
kind: Literal["card"] = "card"
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
MediaSource = TestSource | UriSource | FileSource | ImageSource | CardSource
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def source_to_dict(source: MediaSource) -> dict[str, Any]:
|
|
87
|
+
"""The form the core sends to the output worker."""
|
|
88
|
+
if isinstance(source, FileSource):
|
|
89
|
+
uri = {"kind": "uri", "uri": Path(source.path).resolve().as_uri()}
|
|
90
|
+
return {**uri, "cut": True} if source.cut else uri
|
|
91
|
+
if isinstance(source, ImageSource):
|
|
92
|
+
return {"kind": "image", "path": str(Path(source.path).resolve())}
|
|
93
|
+
if isinstance(source, UriSource):
|
|
94
|
+
uri = {"kind": "uri", "uri": source.uri}
|
|
95
|
+
return {**uri, "audio_uri": source.audio_uri} if source.audio_uri else uri
|
|
96
|
+
return asdict(source)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
@dataclass
|
|
100
|
+
class Metadata:
|
|
101
|
+
"""What is playing right now (F-IN-06); shown on the viewer page and in overlays."""
|
|
102
|
+
|
|
103
|
+
title: str = ""
|
|
104
|
+
channel: str = ""
|
|
105
|
+
thumbnail: str = ""
|
|
106
|
+
duration_s: float | None = None
|
|
107
|
+
submitter: str = ""
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
@dataclass(frozen=True)
|
|
111
|
+
class Viewer:
|
|
112
|
+
"""Who sent a viewer form: a device id (browser cookie) and the LAN address."""
|
|
113
|
+
|
|
114
|
+
device: str
|
|
115
|
+
ip: str
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
@dataclass(frozen=True)
|
|
119
|
+
class ViewerItem:
|
|
120
|
+
"""One line the input shows on the viewer page, e.g. a queue entry."""
|
|
121
|
+
|
|
122
|
+
id: str
|
|
123
|
+
text: str
|
|
124
|
+
detail: str = ""
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
class Rejected(Exception):
|
|
128
|
+
"""Raised in on_viewer_submit(): the message is shown to the viewer as it is."""
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
@dataclass
|
|
132
|
+
class PlaybackEvent:
|
|
133
|
+
"""Sent to the plugin when its source starts, ends or fails on an output."""
|
|
134
|
+
|
|
135
|
+
kind: Literal["started", "ended", "failed"]
|
|
136
|
+
detail: str = ""
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
# --- What the system gives a plugin -----------------------------------------------
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
class Store:
|
|
143
|
+
"""A small persistent key-value store per plugin instance (JSON file)."""
|
|
144
|
+
|
|
145
|
+
def __init__(self, path: Path):
|
|
146
|
+
self._path = path
|
|
147
|
+
self._data: dict[str, Any] = json.loads(path.read_text()) if path.exists() else {}
|
|
148
|
+
|
|
149
|
+
def get(self, key: str, default: Any = None) -> Any:
|
|
150
|
+
return self._data.get(key, default)
|
|
151
|
+
|
|
152
|
+
def set(self, key: str, value: Any) -> None:
|
|
153
|
+
self._data[key] = value
|
|
154
|
+
self._path.write_text(json.dumps(self._data))
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
@dataclass
|
|
158
|
+
class PluginContext:
|
|
159
|
+
instance_id: str
|
|
160
|
+
data_dir: Path
|
|
161
|
+
log: logging.Logger
|
|
162
|
+
store: Store
|
|
163
|
+
_publish_metadata: Callable[[Metadata], None] = field(repr=False)
|
|
164
|
+
_request_next: Callable[[], None] = field(repr=False, default=lambda: None)
|
|
165
|
+
_publish_info: Callable[[str], None] = field(repr=False, default=lambda _text: None)
|
|
166
|
+
_publish_changed: Callable[[], None] = field(repr=False, default=lambda: None)
|
|
167
|
+
|
|
168
|
+
def set_metadata(self, metadata: Metadata) -> None:
|
|
169
|
+
"""Publishes what is playing now."""
|
|
170
|
+
self._publish_metadata(metadata)
|
|
171
|
+
|
|
172
|
+
def next_source(self) -> None:
|
|
173
|
+
"""Asks for get_source() to be called again while this input stays on air.
|
|
174
|
+
|
|
175
|
+
For inputs with several items (a playlist): when an item has ended and
|
|
176
|
+
no other input takes over, the next item follows without a gap.
|
|
177
|
+
"""
|
|
178
|
+
self._request_next()
|
|
179
|
+
|
|
180
|
+
def set_info(self, text: str) -> None:
|
|
181
|
+
"""A short status line for the admin page, e.g. "3 videos waiting · yt-dlp 2026.10.1"."""
|
|
182
|
+
self._publish_info(text)
|
|
183
|
+
|
|
184
|
+
def changed(self) -> None:
|
|
185
|
+
"""Tells the pages that viewer_items() changed (e.g. a new queue entry)."""
|
|
186
|
+
self._publish_changed()
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
# --- The plugin base class ------------------------------------------------------------
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
class NoSettings(BaseModel):
|
|
193
|
+
pass
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
class InputPlugin[S: BaseModel]:
|
|
197
|
+
"""Base class of every input plugin. Override what you need.
|
|
198
|
+
|
|
199
|
+
Required: get_source(). Usually also is_active(). Everything else has a
|
|
200
|
+
sensible default.
|
|
201
|
+
"""
|
|
202
|
+
|
|
203
|
+
Settings: ClassVar[type[BaseModel]] = NoSettings
|
|
204
|
+
#: How often the default watch_active() calls is_active() (F-AKT-02).
|
|
205
|
+
poll_interval_s: ClassVar[float] = 5.0
|
|
206
|
+
#: A form viewers can send from the player page (F-IN-04), e.g. "submit a link".
|
|
207
|
+
#: The page builds it from the model's JSON Schema; see on_viewer_submit().
|
|
208
|
+
ViewerForm: ClassVar[type[BaseModel] | None] = None
|
|
209
|
+
#: Heading of the form and its list on the player page.
|
|
210
|
+
viewer_title: ClassVar[str] = ""
|
|
211
|
+
|
|
212
|
+
def __init__(self, ctx: PluginContext, settings: S):
|
|
213
|
+
self.ctx = ctx
|
|
214
|
+
self.settings = settings
|
|
215
|
+
|
|
216
|
+
async def start(self) -> None:
|
|
217
|
+
"""Called once after creation, before anything else."""
|
|
218
|
+
|
|
219
|
+
async def stop(self) -> None:
|
|
220
|
+
"""Called once before the process ends."""
|
|
221
|
+
|
|
222
|
+
async def is_active(self) -> bool:
|
|
223
|
+
"""Whether the input has something to show right now."""
|
|
224
|
+
return True
|
|
225
|
+
|
|
226
|
+
async def watch_active(self) -> AsyncIterator[bool]:
|
|
227
|
+
"""Yields the activity whenever it changes.
|
|
228
|
+
|
|
229
|
+
The default polls is_active(). Override it to push changes at once,
|
|
230
|
+
e.g. from a websocket or a queue (F-AKT-02).
|
|
231
|
+
"""
|
|
232
|
+
last: bool | None = None
|
|
233
|
+
while True:
|
|
234
|
+
active = await self.is_active()
|
|
235
|
+
if active != last:
|
|
236
|
+
yield active
|
|
237
|
+
last = active
|
|
238
|
+
await asyncio.sleep(self.poll_interval_s)
|
|
239
|
+
|
|
240
|
+
async def get_source(self) -> MediaSource:
|
|
241
|
+
"""What to play when this input goes on air."""
|
|
242
|
+
raise NotImplementedError
|
|
243
|
+
|
|
244
|
+
async def can_release(self) -> bool:
|
|
245
|
+
"""On equal priority: may another input take over now? (e.g. "video has ended")"""
|
|
246
|
+
return True
|
|
247
|
+
|
|
248
|
+
async def on_playback(self, event: PlaybackEvent) -> None:
|
|
249
|
+
"""Called when this input's source starts, ends or fails on air."""
|
|
250
|
+
|
|
251
|
+
async def on_viewer_submit(self, form: BaseModel, viewer: Viewer) -> str:
|
|
252
|
+
"""A viewer sent the ViewerForm. Return a message for them, or raise Rejected."""
|
|
253
|
+
raise Rejected("this input takes no submissions")
|
|
254
|
+
|
|
255
|
+
async def viewer_items(self) -> list[ViewerItem]:
|
|
256
|
+
"""Lines shown under the form on the player page (e.g. the queue)."""
|
|
257
|
+
return []
|
|
258
|
+
|
|
259
|
+
async def remove_item(self, item_id: str) -> None:
|
|
260
|
+
"""The admin removes one of the viewer_items() (e.g. a queue entry)."""
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"""`python -m lanstreamer_sdk new`: a new plugin folder that works from the first minute."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from lanstreamer_sdk.manifest import ID_PATTERN, ManifestError, check_id_is_free
|
|
9
|
+
|
|
10
|
+
_MANIFEST = """\
|
|
11
|
+
id = "@ID@"
|
|
12
|
+
name = @NAME@
|
|
13
|
+
version = "0.1.0"
|
|
14
|
+
api_version = 1
|
|
15
|
+
entry = "@MODULE@:Plugin"
|
|
16
|
+
description = "One sentence for the admin page."
|
|
17
|
+
# requirements = ["requests>=2.32"] # packages from PyPI, installed at upload
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
_MODULE = '''\
|
|
21
|
+
"""@TITLE@ – an input plugin for lan-streamer (plugin API 1).
|
|
22
|
+
|
|
23
|
+
It already runs: it is always active and plays a test picture. Replace is_active()
|
|
24
|
+
and get_source() with your source, e.g. `return UriSource(self.settings.url)`.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from pydantic import BaseModel, Field
|
|
28
|
+
|
|
29
|
+
from lanstreamer_sdk import InputPlugin, Metadata, TestSource
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class Settings(BaseModel):
|
|
33
|
+
# the admin page builds its form from this model
|
|
34
|
+
pattern: str = Field("smpte", description="Test picture: smpte, colors, ball, snow, ...")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class Plugin(InputPlugin[Settings]):
|
|
38
|
+
Settings = Settings
|
|
39
|
+
|
|
40
|
+
async def is_active(self) -> bool:
|
|
41
|
+
"""Do I have something to show right now? Asked every poll_interval_s seconds."""
|
|
42
|
+
return True
|
|
43
|
+
|
|
44
|
+
async def get_source(self):
|
|
45
|
+
"""What should be played when this input goes on air?"""
|
|
46
|
+
self.ctx.set_metadata(Metadata(title=@NAME@))
|
|
47
|
+
return TestSource(pattern=self.settings.pattern)
|
|
48
|
+
'''
|
|
49
|
+
|
|
50
|
+
_CONFTEST = """\
|
|
51
|
+
import sys
|
|
52
|
+
from pathlib import Path
|
|
53
|
+
|
|
54
|
+
# the plugin module sits in the folder above, as in lan-streamer's plugin process
|
|
55
|
+
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
_TEST = """\
|
|
59
|
+
import asyncio
|
|
60
|
+
|
|
61
|
+
from @MODULE@ import Plugin, Settings
|
|
62
|
+
|
|
63
|
+
from lanstreamer_sdk import TestSource
|
|
64
|
+
from lanstreamer_sdk.testing import make_context
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def test_plays_the_test_picture(tmp_path):
|
|
68
|
+
async def steps():
|
|
69
|
+
ctx, seen = make_context(tmp_path)
|
|
70
|
+
plugin = Plugin(ctx, Settings(pattern="ball"))
|
|
71
|
+
await plugin.start()
|
|
72
|
+
assert await plugin.is_active()
|
|
73
|
+
assert await plugin.get_source() == TestSource(pattern="ball")
|
|
74
|
+
assert seen.metadata[-1].title == @NAME@
|
|
75
|
+
|
|
76
|
+
asyncio.run(steps())
|
|
77
|
+
"""
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def module_name(plugin_id: str) -> str:
|
|
81
|
+
"""Always ends in _plugin, so that it cannot shadow a package the plugin imports."""
|
|
82
|
+
name = plugin_id.replace("-", "_")
|
|
83
|
+
if not name.endswith("_plugin"):
|
|
84
|
+
name += "_plugin"
|
|
85
|
+
return f"plugin_{name}" if name[0].isdigit() else name
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def new_plugin(folder: Path, name: str = "") -> Path:
|
|
89
|
+
"""Creates a working plugin in folder; its name is the plugin's id. Returns the module file."""
|
|
90
|
+
plugin_id = folder.resolve().name
|
|
91
|
+
if not ID_PATTERN.match(plugin_id):
|
|
92
|
+
raise ManifestError(
|
|
93
|
+
f"'{plugin_id}' cannot be a plugin id: 2–49 characters, a-z, 0-9 and '-', "
|
|
94
|
+
"starting with a letter or digit"
|
|
95
|
+
)
|
|
96
|
+
check_id_is_free(plugin_id)
|
|
97
|
+
if folder.exists() and any(folder.iterdir()):
|
|
98
|
+
raise ManifestError(f"{folder} already exists and is not empty")
|
|
99
|
+
|
|
100
|
+
module = module_name(plugin_id)
|
|
101
|
+
title = name or plugin_id.replace("-", " ").capitalize()
|
|
102
|
+
def fill(template: str) -> str:
|
|
103
|
+
return (template.replace("@ID@", plugin_id).replace("@MODULE@", module)
|
|
104
|
+
.replace("@NAME@", json.dumps(title, ensure_ascii=False)).replace("@TITLE@", title)) # fmt: skip
|
|
105
|
+
|
|
106
|
+
(folder / "tests").mkdir(parents=True, exist_ok=True)
|
|
107
|
+
for path, template in (
|
|
108
|
+
(folder / "plugin.toml", _MANIFEST),
|
|
109
|
+
(folder / f"{module}.py", _MODULE),
|
|
110
|
+
(folder / "tests" / "conftest.py", _CONFTEST),
|
|
111
|
+
(folder / "tests" / f"test_{module}.py", _TEST),
|
|
112
|
+
):
|
|
113
|
+
path.write_text(fill(template), encoding="utf-8")
|
|
114
|
+
return folder / f"{module}.py"
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""Helpers to test a plugin without lan-streamer running (e.g. with pytest).
|
|
2
|
+
|
|
3
|
+
from lanstreamer_sdk.testing import make_context
|
|
4
|
+
|
|
5
|
+
async def test_title(tmp_path):
|
|
6
|
+
ctx, seen = make_context(tmp_path)
|
|
7
|
+
plugin = Plugin(ctx, Settings())
|
|
8
|
+
await plugin.start()
|
|
9
|
+
source = await plugin.get_source()
|
|
10
|
+
assert seen.metadata[-1].title == "…"
|
|
11
|
+
|
|
12
|
+
The plugin talks to the context exactly as in lan-streamer; `seen` records what
|
|
13
|
+
it published. To test it in a real plugin process, see docs/plugin-guide.md.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import logging
|
|
19
|
+
from dataclasses import dataclass, field
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
|
|
22
|
+
from lanstreamer_sdk.plugin import Metadata, PluginContext, Store
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass
|
|
26
|
+
class Recorded:
|
|
27
|
+
"""What a plugin published through its context, oldest first."""
|
|
28
|
+
|
|
29
|
+
metadata: list[Metadata] = field(default_factory=list)
|
|
30
|
+
info: list[str] = field(default_factory=list)
|
|
31
|
+
next_requests: int = 0 # ctx.next_source() calls
|
|
32
|
+
changes: int = 0 # ctx.changed() calls
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def make_context(data_dir: Path, instance_id: str = "test") -> tuple[PluginContext, Recorded]:
|
|
36
|
+
"""A PluginContext with its own data folder and store, recording what is published."""
|
|
37
|
+
data_dir.mkdir(parents=True, exist_ok=True)
|
|
38
|
+
seen = Recorded()
|
|
39
|
+
|
|
40
|
+
def request_next() -> None:
|
|
41
|
+
seen.next_requests += 1
|
|
42
|
+
|
|
43
|
+
def changed() -> None:
|
|
44
|
+
seen.changes += 1
|
|
45
|
+
|
|
46
|
+
ctx = PluginContext(
|
|
47
|
+
instance_id=instance_id,
|
|
48
|
+
data_dir=data_dir,
|
|
49
|
+
log=logging.getLogger(instance_id),
|
|
50
|
+
store=Store(data_dir / "store.json"),
|
|
51
|
+
_publish_metadata=seen.metadata.append,
|
|
52
|
+
_request_next=request_next,
|
|
53
|
+
_publish_info=lambda text: seen.info.append(str(text)),
|
|
54
|
+
_publish_changed=changed,
|
|
55
|
+
)
|
|
56
|
+
return ctx, seen
|
lanstreamer_sdk/tools.py
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"""Tools for plugin authors: start a plugin, check its folder, pack it for the upload.
|
|
2
|
+
|
|
3
|
+
python -m lanstreamer_sdk new <folder> [--name '<display name>']
|
|
4
|
+
creates a working plugin; the folder's name becomes the plugin's id.
|
|
5
|
+
python -m lanstreamer_sdk check <folder> [--settings '<json>']
|
|
6
|
+
loads the plugin like lan-streamer does and prints its settings form
|
|
7
|
+
(JSON Schema), the validated settings and its viewer form, or the error.
|
|
8
|
+
python -m lanstreamer_sdk pack <folder> [-o <file.zip>]
|
|
9
|
+
zips the plugin for the admin page (Plugins → Upload), without caches,
|
|
10
|
+
tests and installed requirements.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import argparse
|
|
16
|
+
import importlib
|
|
17
|
+
import json
|
|
18
|
+
import sys
|
|
19
|
+
import zipfile
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from typing import Any
|
|
22
|
+
|
|
23
|
+
from pydantic import ValidationError
|
|
24
|
+
|
|
25
|
+
from lanstreamer_sdk.manifest import ManifestError, check_id_is_free, load_manifest
|
|
26
|
+
from lanstreamer_sdk.plugin import InputPlugin
|
|
27
|
+
from lanstreamer_sdk.scaffold import new_plugin
|
|
28
|
+
|
|
29
|
+
DEPS = ".deps" # requirements installed at upload (lanstreamer/plugins/install.py)
|
|
30
|
+
# never in the upload: caches, installed requirements, the plugin's own tests
|
|
31
|
+
SKIP_DIRS = {"__pycache__", DEPS, "tests", ".git", ".venv", "venv", ".pytest_cache", ".ruff_cache"}
|
|
32
|
+
SKIP_SUFFIXES = {".pyc", ".pyo", ".zip"}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def load_plugin(folder: Path) -> type[InputPlugin]:
|
|
36
|
+
"""Imports the plugin class named in folder/plugin.toml (its `entry`)."""
|
|
37
|
+
manifest = load_manifest(folder)
|
|
38
|
+
module_name, class_name = manifest.entry.split(":")
|
|
39
|
+
sys.path.insert(0, str(folder))
|
|
40
|
+
if (folder / DEPS).is_dir():
|
|
41
|
+
sys.path.insert(0, str(folder / DEPS))
|
|
42
|
+
cls = getattr(importlib.import_module(module_name), class_name)
|
|
43
|
+
if not (isinstance(cls, type) and issubclass(cls, InputPlugin)):
|
|
44
|
+
raise ManifestError(f"{manifest.entry} is not an InputPlugin")
|
|
45
|
+
return cls
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def check(folder: Path, settings: dict[str, Any] | None = None) -> dict[str, Any]:
|
|
49
|
+
"""What the admin page gets from the plugin; raises on a broken plugin."""
|
|
50
|
+
manifest = load_manifest(folder)
|
|
51
|
+
check_id_is_free(manifest.id)
|
|
52
|
+
cls = load_plugin(folder)
|
|
53
|
+
result: dict[str, Any] = {
|
|
54
|
+
"id": manifest.id, "version": manifest.version, "api_version": manifest.api_version,
|
|
55
|
+
"schema": cls.Settings.model_json_schema(), "errors": [],
|
|
56
|
+
"viewer_form": None if cls.ViewerForm is None else {
|
|
57
|
+
"title": cls.viewer_title, "schema": cls.ViewerForm.model_json_schema(),
|
|
58
|
+
},
|
|
59
|
+
} # fmt: skip
|
|
60
|
+
try:
|
|
61
|
+
result["settings"] = cls.Settings.model_validate(settings or {}).model_dump(mode="json")
|
|
62
|
+
except ValidationError as exc:
|
|
63
|
+
result["errors"] = [
|
|
64
|
+
{"field": ".".join(str(p) for p in e["loc"]), "message": e["msg"]} for e in exc.errors()
|
|
65
|
+
]
|
|
66
|
+
return result
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def pack(folder: Path, out: Path | None = None) -> Path:
|
|
70
|
+
"""Zips the plugin folder (plugin.toml at the top of the zip)."""
|
|
71
|
+
manifest = load_manifest(folder)
|
|
72
|
+
check_id_is_free(manifest.id)
|
|
73
|
+
out = out or folder.parent / f"{manifest.id}-{manifest.version}.zip"
|
|
74
|
+
files = sorted(
|
|
75
|
+
p for p in folder.rglob("*")
|
|
76
|
+
if p.is_file() and p.suffix not in SKIP_SUFFIXES
|
|
77
|
+
and not any(part in SKIP_DIRS for part in p.relative_to(folder).parts[:-1])
|
|
78
|
+
) # fmt: skip
|
|
79
|
+
with zipfile.ZipFile(out, "w", zipfile.ZIP_DEFLATED) as archive:
|
|
80
|
+
for path in files:
|
|
81
|
+
archive.write(path, path.relative_to(folder).as_posix())
|
|
82
|
+
return out
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def main(argv: list[str] | None = None) -> int:
|
|
86
|
+
parser = argparse.ArgumentParser(prog="python -m lanstreamer_sdk", description=__doc__,
|
|
87
|
+
formatter_class=argparse.RawDescriptionHelpFormatter) # fmt: skip
|
|
88
|
+
commands = parser.add_subparsers(dest="command", required=True)
|
|
89
|
+
new_cmd = commands.add_parser("new", help="create a working plugin in a new folder")
|
|
90
|
+
new_cmd.add_argument("folder", type=Path)
|
|
91
|
+
new_cmd.add_argument("--name", default="", help="display name in the admin page")
|
|
92
|
+
check_cmd = commands.add_parser("check", help="load the plugin and show its forms")
|
|
93
|
+
check_cmd.add_argument("folder", type=Path)
|
|
94
|
+
check_cmd.add_argument("--settings", default="{}", help="settings to validate, as JSON")
|
|
95
|
+
pack_cmd = commands.add_parser("pack", help="zip the plugin for the upload")
|
|
96
|
+
pack_cmd.add_argument("folder", type=Path)
|
|
97
|
+
pack_cmd.add_argument("-o", "--output", type=Path)
|
|
98
|
+
args = parser.parse_args(argv)
|
|
99
|
+
|
|
100
|
+
try:
|
|
101
|
+
if args.command == "new":
|
|
102
|
+
print(f"created {new_plugin(args.folder, args.name)}")
|
|
103
|
+
return 0
|
|
104
|
+
if args.command == "check":
|
|
105
|
+
result = check(args.folder, json.loads(args.settings))
|
|
106
|
+
print(json.dumps(result, indent=2))
|
|
107
|
+
return 1 if result["errors"] else 0
|
|
108
|
+
print(pack(args.folder, args.output))
|
|
109
|
+
return 0
|
|
110
|
+
except (ManifestError, ImportError, AttributeError, ValueError) as exc:
|
|
111
|
+
print(f"error: {exc.__class__.__name__}: {exc}", file=sys.stderr)
|
|
112
|
+
return 2
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: lanstreamer-sdk
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Write input plugins for lan-streamer
|
|
5
|
+
Project-URL: Guide, https://github.com/zGLados/lan-streamer/blob/main/docs/plugin-guide.md
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Requires-Python: >=3.12
|
|
8
|
+
Requires-Dist: pydantic>=2.7
|
|
9
|
+
Provides-Extra: test
|
|
10
|
+
Requires-Dist: pytest>=8; extra == 'test'
|
|
11
|
+
Description-Content-Type: text/markdown
|
|
12
|
+
|
|
13
|
+
# lanstreamer-sdk
|
|
14
|
+
|
|
15
|
+
Write input plugins for [lan-streamer](https://github.com/zGLados/lan-streamer) without installing lan-streamer itself.
|
|
16
|
+
|
|
17
|
+
```
|
|
18
|
+
python -m lanstreamer_sdk new my-plugin # a working plugin in a new folder
|
|
19
|
+
python -m lanstreamer_sdk check my-plugin # loads it like lan-streamer, shows the forms
|
|
20
|
+
python -m pytest my-plugin # its tests
|
|
21
|
+
python -m lanstreamer_sdk pack my-plugin # the zip for the admin page (Plugins → Upload)
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
Read the [plugin guide](https://github.com/zGLados/lan-streamer/blob/main/docs/plugin-guide.md). The major version of this package is the plugin API version.
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
lanstreamer_sdk/__init__.py,sha256=wb5iLdeJh-b04uk2Uc_Y2pzAdfJ7qIyatH-hUAbEzSg,1512
|
|
2
|
+
lanstreamer_sdk/__main__.py,sha256=Yj1SZbo4ZEpQU2J--dIETYguOL7HRdh_ru8GUv3s7nE,69
|
|
3
|
+
lanstreamer_sdk/manifest.py,sha256=nyCQF9DAisjcMbcO7Mkoi60_gsiMxx6Y7HdKxdQPH7o,2808
|
|
4
|
+
lanstreamer_sdk/plugin.py,sha256=IRE3j4On7XhmerPdHzQZKY5fF1hwyVZ7U0lDZrs0gyo,7941
|
|
5
|
+
lanstreamer_sdk/scaffold.py,sha256=7vV-Xisoq6h3ZjUyBTI7CWguKpypWz7T4iR_NwDXdYk,3752
|
|
6
|
+
lanstreamer_sdk/testing.py,sha256=NMPNioAkSIs0d4Sz6VkOifnEdnHp_FQgCfceDHyU3Yg,1787
|
|
7
|
+
lanstreamer_sdk/tools.py,sha256=YeS068hA1xMMv_lhvJotpPSUHXdQ4SBEQUhhr6Km-Bs,5031
|
|
8
|
+
lanstreamer_sdk-1.0.0.dist-info/METADATA,sha256=PbFpI8zJAmRHGmaTLoXW-Aeq6AGJllsX42jvZJUit60,1003
|
|
9
|
+
lanstreamer_sdk-1.0.0.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
|
|
10
|
+
lanstreamer_sdk-1.0.0.dist-info/RECORD,,
|