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.
@@ -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,5 @@
1
+ import sys
2
+
3
+ from lanstreamer_sdk.tools import main
4
+
5
+ sys.exit(main())
@@ -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
@@ -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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.33.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any