pytellybox 0.1.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.
- pytellybox/__init__.py +37 -0
- pytellybox/admin_state.json +21 -0
- pytellybox/client.py +234 -0
- pytellybox/errors.py +35 -0
- pytellybox/mock.py +445 -0
- pytellybox/models.py +267 -0
- pytellybox/py.typed +0 -0
- pytellybox-0.1.0.dist-info/METADATA +57 -0
- pytellybox-0.1.0.dist-info/RECORD +11 -0
- pytellybox-0.1.0.dist-info/WHEEL +4 -0
- pytellybox-0.1.0.dist-info/licenses/LICENSE +202 -0
pytellybox/__init__.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""Async client for Tellybox (https://github.com/sandermvanvliet/Tellybox)."""
|
|
2
|
+
|
|
3
|
+
from pytellybox.client import EXTRA_MINUTES_MAX, TellyboxClient
|
|
4
|
+
from pytellybox.errors import (
|
|
5
|
+
TellyboxAuthError,
|
|
6
|
+
TellyboxConnectionError,
|
|
7
|
+
TellyboxError,
|
|
8
|
+
TellyboxForbiddenError,
|
|
9
|
+
TellyboxNotFoundError,
|
|
10
|
+
TellyboxRequestError,
|
|
11
|
+
TellyboxTimeUpError,
|
|
12
|
+
TellyboxUnavailableError,
|
|
13
|
+
)
|
|
14
|
+
from pytellybox.models import (
|
|
15
|
+
LAST_FIVE_S,
|
|
16
|
+
TV,
|
|
17
|
+
AdminState,
|
|
18
|
+
Day,
|
|
19
|
+
Disk,
|
|
20
|
+
Group,
|
|
21
|
+
Home,
|
|
22
|
+
Info,
|
|
23
|
+
Jobs,
|
|
24
|
+
KidProfile,
|
|
25
|
+
NowPlaying,
|
|
26
|
+
Profile,
|
|
27
|
+
Show,
|
|
28
|
+
ShowRef,
|
|
29
|
+
Tile,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
__all__ = [
|
|
33
|
+
"EXTRA_MINUTES_MAX", "LAST_FIVE_S", "TV", "AdminState", "Day", "Disk", "Group", "Home", "Info", "Jobs",
|
|
34
|
+
"KidProfile", "NowPlaying", "Profile", "Show", "ShowRef", "TellyboxAuthError", "TellyboxClient",
|
|
35
|
+
"TellyboxConnectionError", "TellyboxError", "TellyboxForbiddenError", "TellyboxNotFoundError",
|
|
36
|
+
"TellyboxRequestError", "TellyboxTimeUpError", "TellyboxUnavailableError", "Tile",
|
|
37
|
+
]
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
{
|
|
2
|
+
"instance_id": "3f2a9c0e5b7d4e1f8a6b2c3d4e5f6a7b",
|
|
3
|
+
"version": "2026.09.29.5",
|
|
4
|
+
"api": 1,
|
|
5
|
+
"day": {"date": "2026-09-29", "resets_at": "2026-09-30T02:00:00+00:00"},
|
|
6
|
+
"tv": {"connection": "CONNECTED", "reachable": true, "device": "TV"},
|
|
7
|
+
"now_playing": {"episode_id": 4, "show_id": 2, "title": "Alongside", "show": "Harbour Pups",
|
|
8
|
+
"state": "playing", "position_s": 312, "duration_s": 660, "profile_ids": [1]},
|
|
9
|
+
"group": {"remaining_s": 1790, "time_up": false, "last_five": false, "action": "continue", "reason": null,
|
|
10
|
+
"grace_ends_at": null, "session_started_at": "2026-09-29T13:10:00+00:00", "session_elapsed_s": 1200},
|
|
11
|
+
"profiles": [
|
|
12
|
+
{"id": 1, "name": "Mila", "avatar": "fox", "allowance_s": 3600, "extra_s": 900, "used_s": 2710,
|
|
13
|
+
"remaining_s": 1790, "unlimited": false, "blocked": false, "mode": "ignore_pauses", "max_session_s": 5400,
|
|
14
|
+
"session_elapsed_s": 1200, "can_start": true, "reason": null, "watching": true, "last_five": false},
|
|
15
|
+
{"id": 2, "name": "Noah", "avatar": null, "allowance_s": 2700, "extra_s": 0, "used_s": 2700,
|
|
16
|
+
"remaining_s": 0, "unlimited": false, "blocked": false, "mode": "ignore_pauses", "max_session_s": null,
|
|
17
|
+
"session_elapsed_s": null, "can_start": false, "reason": "allowance", "watching": false, "last_five": true}
|
|
18
|
+
],
|
|
19
|
+
"jobs": {"queued": 1, "running": 1, "failed": 0, "held_ready": 3},
|
|
20
|
+
"disk": {"media_bytes": 48213000000, "free_bytes": 120000000000}
|
|
21
|
+
}
|
pytellybox/client.py
ADDED
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
"""Async client for a Tellybox server: the admin API (token) and the kid API (browse and play)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from collections.abc import AsyncIterator, Sequence
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
import aiohttp
|
|
10
|
+
|
|
11
|
+
from pytellybox.errors import (
|
|
12
|
+
TellyboxAuthError,
|
|
13
|
+
TellyboxConnectionError,
|
|
14
|
+
TellyboxError,
|
|
15
|
+
TellyboxForbiddenError,
|
|
16
|
+
TellyboxNotFoundError,
|
|
17
|
+
TellyboxRequestError,
|
|
18
|
+
TellyboxTimeUpError,
|
|
19
|
+
TellyboxUnavailableError,
|
|
20
|
+
)
|
|
21
|
+
from pytellybox.models import AdminState, Home, Info, KidProfile, Show
|
|
22
|
+
|
|
23
|
+
DEFAULT_TIMEOUT_S = 10.0
|
|
24
|
+
EVENTS_READ_TIMEOUT_S = 45.0 # Tellybox sends a keepalive every 15 s
|
|
25
|
+
EXTRA_MINUTES_MAX = 240
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class TellyboxClient:
|
|
29
|
+
"""One Tellybox server.
|
|
30
|
+
|
|
31
|
+
`base_url` is the server's root, e.g. `https://tellybox.example` or `http://192.0.2.10:8080`; a trailing
|
|
32
|
+
slash is ignored. `token` is an API token (`tbx_...`) created on Tellybox's Integrations page; it may be
|
|
33
|
+
None for `info()` and the kid API only. The caller owns `session` (Home Assistant passes its shared one);
|
|
34
|
+
the client never closes it. The token is sent only as `Authorization: Bearer` to `/api/admin/*` and is
|
|
35
|
+
never logged or put in an exception message.
|
|
36
|
+
|
|
37
|
+
Error mapping, for every call: connection problems and timeouts raise TellyboxConnectionError;
|
|
38
|
+
401 TellyboxAuthError; 403 TellyboxForbiddenError; 404 TellyboxNotFoundError; 400/422
|
|
39
|
+
TellyboxRequestError (with Tellybox's `detail`); 409 on play TellyboxTimeUpError; 503
|
|
40
|
+
TellyboxUnavailableError; any other non-2xx TellyboxError.
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
def __init__(
|
|
44
|
+
self, base_url: str, token: str | None, session: aiohttp.ClientSession, *, timeout: float = DEFAULT_TIMEOUT_S
|
|
45
|
+
) -> None:
|
|
46
|
+
self._base_url = base_url.strip().rstrip("/")
|
|
47
|
+
self._token = token
|
|
48
|
+
self._session = session
|
|
49
|
+
self._timeout = aiohttp.ClientTimeout(total=timeout)
|
|
50
|
+
|
|
51
|
+
def __repr__(self) -> str:
|
|
52
|
+
return f"TellyboxClient({self._base_url!r})"
|
|
53
|
+
|
|
54
|
+
@property
|
|
55
|
+
def base_url(self) -> str:
|
|
56
|
+
"""The normalised base URL, without a trailing slash."""
|
|
57
|
+
return self._base_url
|
|
58
|
+
|
|
59
|
+
def url(self, path: str) -> str:
|
|
60
|
+
"""Absolute URL for a server path such as `/img/episode/4.jpg` (images need no auth)."""
|
|
61
|
+
return f"{self._base_url}/{path.lstrip('/')}"
|
|
62
|
+
|
|
63
|
+
# ----------------------------------------------------------------------- plumbing
|
|
64
|
+
|
|
65
|
+
def _headers(self, path: str) -> dict[str, str]:
|
|
66
|
+
if self._token and path.startswith("/api/admin/"):
|
|
67
|
+
return {"Authorization": f"Bearer {self._token}"}
|
|
68
|
+
return {}
|
|
69
|
+
|
|
70
|
+
async def _request(
|
|
71
|
+
self, method: str, path: str, *, json_body: Any = None, params: dict[str, str] | None = None,
|
|
72
|
+
conflict_is_time_up: bool = False,
|
|
73
|
+
) -> Any:
|
|
74
|
+
"""One JSON request; returns the decoded body (None when empty) or raises a TellyboxError."""
|
|
75
|
+
try:
|
|
76
|
+
async with self._session.request(
|
|
77
|
+
method, self.url(path), headers=self._headers(path), json=json_body, params=params,
|
|
78
|
+
timeout=self._timeout,
|
|
79
|
+
) as resp:
|
|
80
|
+
if resp.status >= 400:
|
|
81
|
+
raise await _error_for(resp, conflict_is_time_up)
|
|
82
|
+
raw = await resp.read()
|
|
83
|
+
except (aiohttp.ClientError, TimeoutError) as err:
|
|
84
|
+
raise TellyboxConnectionError(f"Cannot reach Tellybox: {type(err).__name__}") from None
|
|
85
|
+
if not raw:
|
|
86
|
+
return None
|
|
87
|
+
try:
|
|
88
|
+
return json.loads(raw)
|
|
89
|
+
except ValueError:
|
|
90
|
+
raise TellyboxError("Tellybox sent a response that is not JSON") from None
|
|
91
|
+
|
|
92
|
+
# ----------------------------------------------------------------------- admin API (docs/admin-api.md)
|
|
93
|
+
|
|
94
|
+
async def info(self) -> Info:
|
|
95
|
+
"""`GET /api/info`, no token needed."""
|
|
96
|
+
return Info.from_dict(await self._request("GET", "/api/info"))
|
|
97
|
+
|
|
98
|
+
async def state(self) -> AdminState:
|
|
99
|
+
"""`GET /api/admin/state` (read scope)."""
|
|
100
|
+
return AdminState.from_dict(await self._request("GET", "/api/admin/state"))
|
|
101
|
+
|
|
102
|
+
async def events(self) -> AsyncIterator[AdminState]:
|
|
103
|
+
"""`GET /api/admin/events` (read scope): yields the current state at once, then one per change.
|
|
104
|
+
|
|
105
|
+
Keepalive comments are skipped. The read timeout is EVENTS_READ_TIMEOUT_S. When the stream ends or
|
|
106
|
+
breaks, the iterator raises TellyboxConnectionError; it never reconnects on its own (the caller
|
|
107
|
+
decides the backoff). A 401 on connect raises TellyboxAuthError.
|
|
108
|
+
"""
|
|
109
|
+
path = "/api/admin/events"
|
|
110
|
+
timeout = aiohttp.ClientTimeout(total=None, sock_connect=self._timeout.total, sock_read=EVENTS_READ_TIMEOUT_S)
|
|
111
|
+
headers = {**self._headers(path), "Accept": "text/event-stream"}
|
|
112
|
+
try:
|
|
113
|
+
async with self._session.get(self.url(path), headers=headers, timeout=timeout) as resp:
|
|
114
|
+
if resp.status >= 400:
|
|
115
|
+
raise await _error_for(resp, False)
|
|
116
|
+
data: list[str] = []
|
|
117
|
+
async for raw in resp.content:
|
|
118
|
+
line = raw.decode("utf-8", errors="replace").rstrip("\r\n")
|
|
119
|
+
if not line:
|
|
120
|
+
if data:
|
|
121
|
+
payload, data = "\n".join(data), []
|
|
122
|
+
yield _parse_event(payload)
|
|
123
|
+
elif line.startswith(":"):
|
|
124
|
+
continue
|
|
125
|
+
elif line.startswith("data:"):
|
|
126
|
+
value = line[5:]
|
|
127
|
+
data.append(value[1:] if value.startswith(" ") else value)
|
|
128
|
+
except (aiohttp.ClientError, TimeoutError) as err:
|
|
129
|
+
raise TellyboxConnectionError(f"Event stream broke: {type(err).__name__}") from None
|
|
130
|
+
raise TellyboxConnectionError("Event stream ended")
|
|
131
|
+
|
|
132
|
+
async def _override(self, method: str, action: str, **kwargs: Any) -> AdminState:
|
|
133
|
+
return AdminState.from_dict(await self._request(method, f"/api/admin/overrides/{action}", **kwargs))
|
|
134
|
+
|
|
135
|
+
async def add_time(self, minutes: int, profile_ids: Sequence[int] | None = None) -> AdminState:
|
|
136
|
+
"""`POST /api/admin/overrides/extra` (control). `minutes` 1..240; None = every kid."""
|
|
137
|
+
if isinstance(minutes, bool) or not isinstance(minutes, int) or not 1 <= minutes <= EXTRA_MINUTES_MAX:
|
|
138
|
+
raise ValueError(f"minutes must be an integer from 1 to {EXTRA_MINUTES_MAX}")
|
|
139
|
+
return await self._override("POST", "extra", json_body=_body(profile_ids, minutes=minutes))
|
|
140
|
+
|
|
141
|
+
async def set_unlimited(self, profile_ids: Sequence[int] | None = None) -> AdminState:
|
|
142
|
+
"""`POST /api/admin/overrides/unlimited` (control): unlimited today."""
|
|
143
|
+
return await self._override("POST", "unlimited", json_body=_body(profile_ids))
|
|
144
|
+
|
|
145
|
+
async def block(self, profile_ids: Sequence[int] | None = None) -> AdminState:
|
|
146
|
+
"""`POST /api/admin/overrides/block` (control): blocked today, immediately."""
|
|
147
|
+
return await self._override("POST", "block", json_body=_body(profile_ids))
|
|
148
|
+
|
|
149
|
+
async def stop_now(self) -> AdminState:
|
|
150
|
+
"""`POST /api/admin/overrides/stop` (control)."""
|
|
151
|
+
return await self._override("POST", "stop", json_body={})
|
|
152
|
+
|
|
153
|
+
async def clear_today(self, profile_ids: Sequence[int] | None = None) -> AdminState:
|
|
154
|
+
"""`DELETE /api/admin/overrides/today?profile_ids=1,3` (control): clears unlimited and block."""
|
|
155
|
+
params = {"profile_ids": _csv(profile_ids)} if profile_ids is not None else None
|
|
156
|
+
return await self._override("DELETE", "today", params=params)
|
|
157
|
+
|
|
158
|
+
# ----------------------------------------------------------------------- kid API (docs/kid-api.md), no token
|
|
159
|
+
|
|
160
|
+
async def kid_profiles(self) -> list[KidProfile]:
|
|
161
|
+
"""`GET /api/kid/profiles`."""
|
|
162
|
+
return [KidProfile.from_dict(p) for p in await self._request("GET", "/api/kid/profiles")]
|
|
163
|
+
|
|
164
|
+
async def home(self, profile_ids: Sequence[int] | None = None) -> Home:
|
|
165
|
+
"""`GET /api/kid/home?profiles=1,3`."""
|
|
166
|
+
return Home.from_dict(await self._request("GET", "/api/kid/home", params=_profiles_param(profile_ids)))
|
|
167
|
+
|
|
168
|
+
async def show(self, show_id: int, profile_ids: Sequence[int] | None = None) -> Show:
|
|
169
|
+
"""`GET /api/kid/shows/{id}?profiles=1,3`."""
|
|
170
|
+
data = await self._request("GET", f"/api/kid/shows/{int(show_id)}", params=_profiles_param(profile_ids))
|
|
171
|
+
return Show.from_dict(data)
|
|
172
|
+
|
|
173
|
+
async def play(self, episode_id: int, profile_ids: Sequence[int]) -> None:
|
|
174
|
+
"""`POST /api/kid/play`. 409 raises TellyboxTimeUpError: Tellybox's timer always decides."""
|
|
175
|
+
await self._request(
|
|
176
|
+
"POST", "/api/kid/play", json_body={"episode_id": episode_id, "profile_ids": list(profile_ids)},
|
|
177
|
+
conflict_is_time_up=True,
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
async def pause(self) -> None:
|
|
181
|
+
"""`POST /api/kid/pause`."""
|
|
182
|
+
await self._request("POST", "/api/kid/pause", json_body={})
|
|
183
|
+
|
|
184
|
+
async def resume(self) -> None:
|
|
185
|
+
"""`POST /api/kid/resume`."""
|
|
186
|
+
await self._request("POST", "/api/kid/resume", json_body={})
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _csv(ids: Sequence[int]) -> str:
|
|
190
|
+
return ",".join(str(int(i)) for i in ids)
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _body(profile_ids: Sequence[int] | None, **fields: Any) -> dict[str, Any]:
|
|
194
|
+
body = dict(fields)
|
|
195
|
+
if profile_ids is not None:
|
|
196
|
+
body["profile_ids"] = [int(i) for i in profile_ids]
|
|
197
|
+
return body
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _profiles_param(profile_ids: Sequence[int] | None) -> dict[str, str] | None:
|
|
201
|
+
return {"profiles": _csv(profile_ids)} if profile_ids is not None else None
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _parse_event(payload: str) -> AdminState:
|
|
205
|
+
try:
|
|
206
|
+
return AdminState.from_dict(json.loads(payload))
|
|
207
|
+
except (ValueError, KeyError, TypeError):
|
|
208
|
+
raise TellyboxError("Tellybox sent an event that is not a state") from None
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
async def _error_for(resp: aiohttp.ClientResponse, conflict_is_time_up: bool) -> TellyboxError:
|
|
212
|
+
"""Map a non-2xx response to the matching error. Only Tellybox's `detail` ends up in the message."""
|
|
213
|
+
status = resp.status
|
|
214
|
+
detail = ""
|
|
215
|
+
try:
|
|
216
|
+
body = await resp.json(content_type=None)
|
|
217
|
+
except (ValueError, aiohttp.ClientError):
|
|
218
|
+
body = None
|
|
219
|
+
if isinstance(body, dict) and body.get("detail") is not None:
|
|
220
|
+
detail = str(body["detail"])
|
|
221
|
+
text = detail or f"HTTP {status}"
|
|
222
|
+
if status == 401:
|
|
223
|
+
return TellyboxAuthError(text)
|
|
224
|
+
if status == 403:
|
|
225
|
+
return TellyboxForbiddenError(text)
|
|
226
|
+
if status == 404:
|
|
227
|
+
return TellyboxNotFoundError(text)
|
|
228
|
+
if status in (400, 422):
|
|
229
|
+
return TellyboxRequestError(text)
|
|
230
|
+
if status == 409 and conflict_is_time_up:
|
|
231
|
+
return TellyboxTimeUpError(text)
|
|
232
|
+
if status == 503:
|
|
233
|
+
return TellyboxUnavailableError(text)
|
|
234
|
+
return TellyboxError(text)
|
pytellybox/errors.py
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""Errors raised by the Tellybox client. Every error is a `TellyboxError`."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class TellyboxError(Exception):
|
|
7
|
+
"""Base class for every client error."""
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class TellyboxConnectionError(TellyboxError):
|
|
11
|
+
"""Tellybox can't be reached: connection refused, timeout, or an event stream that ended."""
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class TellyboxAuthError(TellyboxError):
|
|
15
|
+
"""401: the token is missing, unknown or revoked. Home Assistant starts a reauth flow."""
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class TellyboxForbiddenError(TellyboxError):
|
|
19
|
+
"""403: the token lacks the scope, e.g. an override with a read-only token."""
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class TellyboxNotFoundError(TellyboxError):
|
|
23
|
+
"""404: no such episode or show (or it isn't visible to kids)."""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class TellyboxRequestError(TellyboxError):
|
|
27
|
+
"""400/422: Tellybox refused the request (bad minutes, unknown profile ids...). `str()` is its detail."""
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class TellyboxTimeUpError(TellyboxError):
|
|
31
|
+
"""409 on play: someone in the group is out of time (PR-4). Playing is refused, never forced."""
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class TellyboxUnavailableError(TellyboxError):
|
|
35
|
+
"""503: Tellybox is up, but its cast service or the TV isn't; nothing was applied."""
|
pytellybox/mock.py
ADDED
|
@@ -0,0 +1,445 @@
|
|
|
1
|
+
"""A mock Tellybox server for developing and testing integrations without a real Tellybox.
|
|
2
|
+
|
|
3
|
+
`python -m pytellybox.mock --port 8099 --token tbx_dev [--read-token tbx_ro]` serves:
|
|
4
|
+
- `/api/info`, `/api/admin/state`, `/api/admin/events` (SSE with keepalive), the override routes, with the
|
|
5
|
+
same auth rules as Tellybox (401 / 403, a read-only token via `read_tokens`);
|
|
6
|
+
- `/api/kid/profiles`, `/home`, `/shows/{id}`, `/state`, `/play` (409 when a watcher can't start), `/pause`,
|
|
7
|
+
`/resume`;
|
|
8
|
+
- `/img/...` placeholder images.
|
|
9
|
+
Overrides change the scripted state the way Tellybox would (extra time, unlimited, block, clear, stop) and
|
|
10
|
+
push a new event. Tests and scripts drive it through `MockTellybox`: `set_state(dict)`, `push()`,
|
|
11
|
+
`calls` (recorded requests), and `app` (an aiohttp `web.Application`).
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import argparse
|
|
17
|
+
import asyncio
|
|
18
|
+
import copy
|
|
19
|
+
import json
|
|
20
|
+
from collections.abc import Awaitable, Callable, Iterable
|
|
21
|
+
from importlib import resources
|
|
22
|
+
from typing import Any
|
|
23
|
+
|
|
24
|
+
from aiohttp import web
|
|
25
|
+
|
|
26
|
+
_LAST_FIVE_S = 300
|
|
27
|
+
_PLACEHOLDER_IMAGE = ( # a 1x1 GIF; the mock has no image library
|
|
28
|
+
b"GIF89a\x01\x00\x01\x00\x80\x00\x00\x80\x80\x80\x00\x00\x00!\xf9\x04\x01\x00\x00\x00\x00,"
|
|
29
|
+
b"\x00\x00\x00\x00\x01\x00\x01\x00\x00\x02\x02D\x01\x00;"
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
_EPISODES = {
|
|
33
|
+
4: {"show_id": 2, "title": "Alongside", "duration_s": 660},
|
|
34
|
+
5: {"show_id": 2, "title": "Lost Ball", "duration_s": 600},
|
|
35
|
+
6: {"show_id": 3, "title": "Big Splash", "duration_s": 480},
|
|
36
|
+
}
|
|
37
|
+
_SHOWS = {2: "Harbour Pups", 3: "Bubble Bay"}
|
|
38
|
+
|
|
39
|
+
Handler = Callable[[web.Request], Awaitable[web.StreamResponse]]
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def default_state() -> dict[str, Any]:
|
|
43
|
+
"""A copy of the packaged example state (Mila watching, Noah out of time)."""
|
|
44
|
+
return json.loads(resources.files("pytellybox").joinpath("admin_state.json").read_text(encoding="utf-8"))
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _json_error(status: int, detail: str) -> web.Response:
|
|
48
|
+
return web.json_response({"detail": detail}, status=status)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class MockTellybox:
|
|
52
|
+
"""A scripted Tellybox. `tokens` may control; `read_tokens` may only read (403 on an override)."""
|
|
53
|
+
|
|
54
|
+
def __init__(
|
|
55
|
+
self, state: dict[str, Any] | None = None, *, token: str = "tbx_mock", read_tokens: Iterable[str] = (),
|
|
56
|
+
keepalive_s: float = 15.0,
|
|
57
|
+
) -> None:
|
|
58
|
+
self.tokens: set[str] = {token}
|
|
59
|
+
self.read_tokens: set[str] = set(read_tokens)
|
|
60
|
+
self.keepalive_s = keepalive_s
|
|
61
|
+
self.calls: list[dict[str, Any]] = []
|
|
62
|
+
self._state: dict[str, Any] = copy.deepcopy(state) if state is not None else default_state()
|
|
63
|
+
self._subscribers: set[asyncio.Queue[str | None]] = set()
|
|
64
|
+
self._runner: web.AppRunner | None = None
|
|
65
|
+
self.app = web.Application(middlewares=[self._record, self._auth])
|
|
66
|
+
self.app.on_shutdown.append(self._close_streams)
|
|
67
|
+
self.app.add_routes([
|
|
68
|
+
web.get("/api/info", self._info),
|
|
69
|
+
web.get("/api/admin/state", self._get_state),
|
|
70
|
+
web.get("/api/admin/events", self._events),
|
|
71
|
+
web.post("/api/admin/overrides/extra", self._extra),
|
|
72
|
+
web.post("/api/admin/overrides/unlimited", self._unlimited),
|
|
73
|
+
web.post("/api/admin/overrides/block", self._block),
|
|
74
|
+
web.post("/api/admin/overrides/stop", self._stop),
|
|
75
|
+
web.delete("/api/admin/overrides/today", self._clear),
|
|
76
|
+
web.get("/api/kid/profiles", self._kid_profiles),
|
|
77
|
+
web.get("/api/kid/home", self._kid_home),
|
|
78
|
+
web.get("/api/kid/shows/{show_id}", self._kid_show),
|
|
79
|
+
web.get("/api/kid/state", self._kid_state),
|
|
80
|
+
web.post("/api/kid/play", self._kid_play),
|
|
81
|
+
web.post("/api/kid/pause", self._kid_pause),
|
|
82
|
+
web.post("/api/kid/resume", self._kid_resume),
|
|
83
|
+
web.get("/img/{kind}/{item}.jpg", self._image),
|
|
84
|
+
])
|
|
85
|
+
|
|
86
|
+
# ------------------------------------------------------------------ driving the mock
|
|
87
|
+
|
|
88
|
+
@property
|
|
89
|
+
def state(self) -> dict[str, Any]:
|
|
90
|
+
"""The current AdminState payload (mutable; call `push()` after changing it)."""
|
|
91
|
+
return self._state
|
|
92
|
+
|
|
93
|
+
def set_state(self, state: dict[str, Any]) -> None:
|
|
94
|
+
"""Replace the state and push it to the event streams."""
|
|
95
|
+
self._state = copy.deepcopy(state)
|
|
96
|
+
self.push()
|
|
97
|
+
|
|
98
|
+
def push(self) -> None:
|
|
99
|
+
"""Send the current state to every open event stream."""
|
|
100
|
+
payload = json.dumps(self._state)
|
|
101
|
+
for queue in self._subscribers:
|
|
102
|
+
queue.put_nowait(payload)
|
|
103
|
+
|
|
104
|
+
async def start(self, host: str = "127.0.0.1", port: int = 0) -> str:
|
|
105
|
+
"""Listen on host:port (0 = any free port) and return the base URL."""
|
|
106
|
+
self._runner = web.AppRunner(self.app)
|
|
107
|
+
await self._runner.setup()
|
|
108
|
+
site = web.TCPSite(self._runner, host, port)
|
|
109
|
+
await site.start()
|
|
110
|
+
actual = self._runner.addresses[0][1]
|
|
111
|
+
return f"http://{host}:{actual}"
|
|
112
|
+
|
|
113
|
+
async def stop(self) -> None:
|
|
114
|
+
if self._runner is not None:
|
|
115
|
+
await self._runner.cleanup()
|
|
116
|
+
self._runner = None
|
|
117
|
+
|
|
118
|
+
# ------------------------------------------------------------------ middleware
|
|
119
|
+
|
|
120
|
+
@web.middleware
|
|
121
|
+
async def _record(self, request: web.Request, handler: Handler) -> web.StreamResponse:
|
|
122
|
+
entry: dict[str, Any] = {"method": request.method, "path": request.path, "query": dict(request.query),
|
|
123
|
+
"json": None}
|
|
124
|
+
entry["json"] = await self._json(request)
|
|
125
|
+
self.calls.append(entry)
|
|
126
|
+
return await handler(request)
|
|
127
|
+
|
|
128
|
+
@web.middleware
|
|
129
|
+
async def _auth(self, request: web.Request, handler: Handler) -> web.StreamResponse:
|
|
130
|
+
if request.path.startswith("/api/admin/"):
|
|
131
|
+
header = request.headers.get("Authorization", "")
|
|
132
|
+
token = header[7:] if header.startswith("Bearer ") else ""
|
|
133
|
+
if token not in self.tokens | self.read_tokens:
|
|
134
|
+
resp = _json_error(401, "unauthorized")
|
|
135
|
+
resp.headers["WWW-Authenticate"] = "Bearer"
|
|
136
|
+
return resp
|
|
137
|
+
if request.method != "GET" and token not in self.tokens:
|
|
138
|
+
return _json_error(403, "forbidden")
|
|
139
|
+
return await handler(request)
|
|
140
|
+
|
|
141
|
+
# ------------------------------------------------------------------ admin API
|
|
142
|
+
|
|
143
|
+
async def _info(self, request: web.Request) -> web.Response:
|
|
144
|
+
s = self._state
|
|
145
|
+
return web.json_response({"instance_id": s["instance_id"], "version": s["version"], "api": 1,
|
|
146
|
+
"capabilities": ["state", "events", "overrides", "profiles"]})
|
|
147
|
+
|
|
148
|
+
async def _get_state(self, request: web.Request) -> web.Response:
|
|
149
|
+
return web.json_response(self._state)
|
|
150
|
+
|
|
151
|
+
async def _events(self, request: web.Request) -> web.StreamResponse:
|
|
152
|
+
resp = web.StreamResponse(headers={"Content-Type": "text/event-stream", "Cache-Control": "no-cache"})
|
|
153
|
+
await resp.prepare(request)
|
|
154
|
+
queue: asyncio.Queue[str | None] = asyncio.Queue()
|
|
155
|
+
queue.put_nowait(json.dumps(self._state))
|
|
156
|
+
self._subscribers.add(queue)
|
|
157
|
+
try:
|
|
158
|
+
while True:
|
|
159
|
+
try:
|
|
160
|
+
payload = await asyncio.wait_for(queue.get(), self.keepalive_s)
|
|
161
|
+
except TimeoutError:
|
|
162
|
+
await resp.write(b": keepalive\n\n")
|
|
163
|
+
continue
|
|
164
|
+
if payload is None:
|
|
165
|
+
break
|
|
166
|
+
await resp.write(f"data: {payload}\n\n".encode())
|
|
167
|
+
except ConnectionResetError:
|
|
168
|
+
pass
|
|
169
|
+
finally:
|
|
170
|
+
self._subscribers.discard(queue)
|
|
171
|
+
return resp
|
|
172
|
+
|
|
173
|
+
async def _close_streams(self, app: web.Application) -> None:
|
|
174
|
+
for queue in list(self._subscribers):
|
|
175
|
+
queue.put_nowait(None)
|
|
176
|
+
|
|
177
|
+
async def _ids(self, request: web.Request, body: Any) -> list[int] | web.Response:
|
|
178
|
+
"""The profiles an override applies to, or a 422 response."""
|
|
179
|
+
if not isinstance(body, dict):
|
|
180
|
+
return _json_error(422, "body must be a JSON object")
|
|
181
|
+
ids = body.get("profile_ids")
|
|
182
|
+
everyone = [p["id"] for p in self._state["profiles"]]
|
|
183
|
+
if ids is None:
|
|
184
|
+
return everyone
|
|
185
|
+
if (not isinstance(ids, list) or not 1 <= len(ids) <= 20 or len(set(ids)) != len(ids)
|
|
186
|
+
or not all(isinstance(i, int) and not isinstance(i, bool) for i in ids)):
|
|
187
|
+
return _json_error(422, "profile_ids must be 1-20 distinct ids")
|
|
188
|
+
if any(i not in everyone for i in ids):
|
|
189
|
+
return _json_error(422, "unknown profile id")
|
|
190
|
+
return ids
|
|
191
|
+
|
|
192
|
+
@staticmethod
|
|
193
|
+
async def _json(request: web.Request) -> Any:
|
|
194
|
+
"""The JSON body; None when absent or malformed (`read()` caches, so it can run twice)."""
|
|
195
|
+
raw = await request.read()
|
|
196
|
+
try:
|
|
197
|
+
return json.loads(raw) if raw else None
|
|
198
|
+
except ValueError:
|
|
199
|
+
return None
|
|
200
|
+
|
|
201
|
+
async def _body(self, request: web.Request) -> Any:
|
|
202
|
+
"""The JSON body of an override or kid POST; an empty body counts as `{}`, junk as None."""
|
|
203
|
+
raw = await request.read()
|
|
204
|
+
return {} if not raw else await self._json(request)
|
|
205
|
+
|
|
206
|
+
def _profiles(self, ids: list[int]) -> list[dict[str, Any]]:
|
|
207
|
+
return [p for p in self._state["profiles"] if p["id"] in ids]
|
|
208
|
+
|
|
209
|
+
def _refresh(self, watchers: list[int] | None = None) -> None:
|
|
210
|
+
"""Recompute each profile and the group the way the timer would."""
|
|
211
|
+
state = self._state
|
|
212
|
+
for p in state["profiles"]:
|
|
213
|
+
if p["blocked"]:
|
|
214
|
+
p["remaining_s"], p["can_start"], p["reason"] = 0, False, "blocked"
|
|
215
|
+
elif p["unlimited"]:
|
|
216
|
+
p["remaining_s"], p["can_start"], p["reason"] = None, True, None
|
|
217
|
+
else:
|
|
218
|
+
left = max(p["allowance_s"] + (p["extra_s"] or 0) - (p["used_s"] or 0), 0)
|
|
219
|
+
p["remaining_s"], p["can_start"] = left, left > 0
|
|
220
|
+
p["reason"] = None if left > 0 else "allowance"
|
|
221
|
+
p["last_five"] = p["remaining_s"] is not None and 0 < p["remaining_s"] <= _LAST_FIVE_S
|
|
222
|
+
if watchers is None:
|
|
223
|
+
watchers = (state.get("now_playing") or {}).get("profile_ids", [])
|
|
224
|
+
group = [p for p in state["profiles"] if p["id"] in watchers]
|
|
225
|
+
if not group:
|
|
226
|
+
return
|
|
227
|
+
finite = [p["remaining_s"] for p in group if p["remaining_s"] is not None]
|
|
228
|
+
remaining = min(finite) if finite else None
|
|
229
|
+
blocked = any(p["blocked"] for p in group)
|
|
230
|
+
out = any(p["can_start"] is False for p in group)
|
|
231
|
+
g = state["group"]
|
|
232
|
+
g["remaining_s"] = remaining
|
|
233
|
+
g["time_up"] = out
|
|
234
|
+
g["last_five"] = remaining is not None and 0 < remaining <= _LAST_FIVE_S
|
|
235
|
+
g["reason"] = "blocked" if blocked else ("allowance" if out else None)
|
|
236
|
+
g["action"] = "stop_now" if blocked else ("finish_then_stop" if out else "continue")
|
|
237
|
+
|
|
238
|
+
def _stop_playback(self) -> None:
|
|
239
|
+
self._state["now_playing"] = None
|
|
240
|
+
for p in self._state["profiles"]:
|
|
241
|
+
p["watching"] = False
|
|
242
|
+
|
|
243
|
+
async def _apply(self, request: web.Request, change: Callable[[list[dict[str, Any]], Any], None],
|
|
244
|
+
*, needs_ids: bool = True, body: Any = None) -> web.Response:
|
|
245
|
+
if body is None:
|
|
246
|
+
body = await self._body(request)
|
|
247
|
+
ids: list[int] | web.Response = await self._ids(request, body) if needs_ids else []
|
|
248
|
+
if isinstance(ids, web.Response):
|
|
249
|
+
return ids
|
|
250
|
+
watchers = list((self._state.get("now_playing") or {}).get("profile_ids", []))
|
|
251
|
+
change(self._profiles(ids), body)
|
|
252
|
+
self._refresh(watchers)
|
|
253
|
+
self.push()
|
|
254
|
+
return web.json_response(self._state)
|
|
255
|
+
|
|
256
|
+
async def _extra(self, request: web.Request) -> web.Response:
|
|
257
|
+
body = await self._body(request)
|
|
258
|
+
minutes = body.get("minutes") if isinstance(body, dict) else None
|
|
259
|
+
if isinstance(minutes, bool) or not isinstance(minutes, int) or not 1 <= minutes <= 240:
|
|
260
|
+
return _json_error(422, "minutes must be an integer from 1 to 240")
|
|
261
|
+
|
|
262
|
+
def change(profiles: list[dict[str, Any]], _: Any) -> None:
|
|
263
|
+
for p in profiles:
|
|
264
|
+
p["extra_s"] = (p["extra_s"] or 0) + minutes * 60
|
|
265
|
+
|
|
266
|
+
return await self._apply(request, change)
|
|
267
|
+
|
|
268
|
+
async def _unlimited(self, request: web.Request) -> web.Response:
|
|
269
|
+
def change(profiles: list[dict[str, Any]], _: Any) -> None:
|
|
270
|
+
for p in profiles:
|
|
271
|
+
p["unlimited"] = True
|
|
272
|
+
|
|
273
|
+
return await self._apply(request, change)
|
|
274
|
+
|
|
275
|
+
async def _block(self, request: web.Request) -> web.Response:
|
|
276
|
+
def change(profiles: list[dict[str, Any]], _: Any) -> None:
|
|
277
|
+
blocked = {p["id"] for p in profiles}
|
|
278
|
+
for p in profiles:
|
|
279
|
+
p["blocked"] = True
|
|
280
|
+
playing = self._state.get("now_playing")
|
|
281
|
+
if playing and blocked & set(playing["profile_ids"]):
|
|
282
|
+
self._stop_playback()
|
|
283
|
+
|
|
284
|
+
return await self._apply(request, change)
|
|
285
|
+
|
|
286
|
+
async def _stop(self, request: web.Request) -> web.Response:
|
|
287
|
+
return await self._apply(request, lambda _p, _b: self._stop_playback(), needs_ids=False)
|
|
288
|
+
|
|
289
|
+
async def _clear(self, request: web.Request) -> web.Response:
|
|
290
|
+
raw = request.query.get("profile_ids")
|
|
291
|
+
body: dict[str, Any] = {}
|
|
292
|
+
if raw:
|
|
293
|
+
try:
|
|
294
|
+
body["profile_ids"] = [int(i) for i in raw.split(",")]
|
|
295
|
+
except ValueError:
|
|
296
|
+
return _json_error(422, "bad profile_ids")
|
|
297
|
+
|
|
298
|
+
def change(profiles: list[dict[str, Any]], _: Any) -> None:
|
|
299
|
+
for p in profiles:
|
|
300
|
+
p["unlimited"] = p["blocked"] = False
|
|
301
|
+
|
|
302
|
+
return await self._apply(request, change, body=body)
|
|
303
|
+
|
|
304
|
+
# ------------------------------------------------------------------ kid API
|
|
305
|
+
|
|
306
|
+
def _group(self, request: web.Request) -> list[int] | web.Response:
|
|
307
|
+
raw = request.query.get("profiles")
|
|
308
|
+
everyone = [p["id"] for p in self._state["profiles"]]
|
|
309
|
+
if raw is None:
|
|
310
|
+
return everyone[:1]
|
|
311
|
+
try:
|
|
312
|
+
ids = [int(i) for i in raw.split(",")]
|
|
313
|
+
except ValueError:
|
|
314
|
+
return _json_error(400, "bad_profiles")
|
|
315
|
+
if not 1 <= len(ids) <= 20 or len(set(ids)) != len(ids) or any(i not in everyone for i in ids):
|
|
316
|
+
return _json_error(400, "bad_profiles")
|
|
317
|
+
return ids
|
|
318
|
+
|
|
319
|
+
def _kid_state_dict(self) -> dict[str, Any]:
|
|
320
|
+
s = self._state
|
|
321
|
+
playing = s.get("now_playing")
|
|
322
|
+
profiles = {}
|
|
323
|
+
for p in s["profiles"]:
|
|
324
|
+
allowance = p["allowance_s"] + (p["extra_s"] or 0)
|
|
325
|
+
fraction = None if p["remaining_s"] is None else round(p["remaining_s"] / allowance, 3) if allowance else 0
|
|
326
|
+
profiles[str(p["id"])] = {"fraction_left": fraction, "last_five": p["last_five"],
|
|
327
|
+
"unlimited": p["unlimited"], "time_up": p["can_start"] is False}
|
|
328
|
+
return {
|
|
329
|
+
"tv": "ok" if s["tv"]["reachable"] else "unreachable",
|
|
330
|
+
"now_playing": None if not playing else {
|
|
331
|
+
"episode_id": playing["episode_id"], "show_id": playing["show_id"],
|
|
332
|
+
"thumb": f"/img/episode/{playing['episode_id']}.jpg", "title": playing["title"],
|
|
333
|
+
"state": playing["state"]},
|
|
334
|
+
"watching": list(playing["profile_ids"]) if playing else [],
|
|
335
|
+
"sky": {"fraction_left": None, "last_five": s["group"]["last_five"], "unlimited": False},
|
|
336
|
+
"time_up": s["group"]["time_up"], "profiles": profiles, "day": s["day"]["date"],
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
@staticmethod
|
|
340
|
+
def _tile(episode_id: int) -> dict[str, Any]:
|
|
341
|
+
e = _EPISODES[episode_id]
|
|
342
|
+
return {"episode_id": episode_id, "show_id": e["show_id"], "thumb": f"/img/episode/{episode_id}.jpg",
|
|
343
|
+
"title": e["title"], "progress": None, "finished": False}
|
|
344
|
+
|
|
345
|
+
async def _kid_profiles(self, request: web.Request) -> web.Response:
|
|
346
|
+
return web.json_response([
|
|
347
|
+
{"profile_id": p["id"], "name": p["name"], "picture": None, "avatar": p["avatar"],
|
|
348
|
+
"time_up": p["can_start"] is False, "fraction_left": None, "last_five": p["last_five"],
|
|
349
|
+
"unlimited": p["unlimited"]}
|
|
350
|
+
for p in self._state["profiles"]])
|
|
351
|
+
|
|
352
|
+
async def _kid_home(self, request: web.Request) -> web.Response:
|
|
353
|
+
group = self._group(request)
|
|
354
|
+
if isinstance(group, web.Response):
|
|
355
|
+
return group
|
|
356
|
+
return web.json_response({
|
|
357
|
+
"continue": [{**self._tile(5), "kind": "next"}],
|
|
358
|
+
"shows": [{"show_id": i, "artwork": f"/img/show/{i}.jpg", "title": t} for i, t in _SHOWS.items()]})
|
|
359
|
+
|
|
360
|
+
async def _kid_show(self, request: web.Request) -> web.Response:
|
|
361
|
+
group = self._group(request)
|
|
362
|
+
if isinstance(group, web.Response):
|
|
363
|
+
return group
|
|
364
|
+
try:
|
|
365
|
+
show_id = int(request.match_info["show_id"])
|
|
366
|
+
except ValueError:
|
|
367
|
+
return _json_error(404, "not_found")
|
|
368
|
+
if show_id not in _SHOWS:
|
|
369
|
+
return _json_error(404, "not_found")
|
|
370
|
+
return web.json_response({
|
|
371
|
+
"show_id": show_id, "artwork": f"/img/show/{show_id}.jpg", "title": _SHOWS[show_id],
|
|
372
|
+
"episodes": [self._tile(i) for i, e in _EPISODES.items() if e["show_id"] == show_id]})
|
|
373
|
+
|
|
374
|
+
async def _kid_state(self, request: web.Request) -> web.Response:
|
|
375
|
+
return web.json_response(self._kid_state_dict())
|
|
376
|
+
|
|
377
|
+
async def _kid_play(self, request: web.Request) -> web.Response:
|
|
378
|
+
body = await self._body(request)
|
|
379
|
+
if not isinstance(body, dict):
|
|
380
|
+
return _json_error(400, "bad_request")
|
|
381
|
+
ids, episode_id = body.get("profile_ids"), body.get("episode_id")
|
|
382
|
+
everyone = [p["id"] for p in self._state["profiles"]]
|
|
383
|
+
if (not isinstance(ids, list) or not 1 <= len(ids) <= 20 or len(set(ids)) != len(ids)
|
|
384
|
+
or any(i not in everyone for i in ids)):
|
|
385
|
+
return _json_error(400, "bad_profiles")
|
|
386
|
+
if episode_id not in _EPISODES:
|
|
387
|
+
return _json_error(404, "not_found")
|
|
388
|
+
if not self._state["tv"]["reachable"]:
|
|
389
|
+
return web.json_response(self._kid_state_dict(), status=503)
|
|
390
|
+
if any(p["can_start"] is False for p in self._profiles(ids)):
|
|
391
|
+
return web.json_response(self._kid_state_dict(), status=409)
|
|
392
|
+
e = _EPISODES[episode_id]
|
|
393
|
+
self._state["now_playing"] = {
|
|
394
|
+
"episode_id": episode_id, "show_id": e["show_id"], "title": e["title"], "show": _SHOWS[e["show_id"]],
|
|
395
|
+
"state": "playing", "position_s": 0, "duration_s": e["duration_s"], "profile_ids": ids}
|
|
396
|
+
for p in self._state["profiles"]:
|
|
397
|
+
p["watching"] = p["id"] in ids
|
|
398
|
+
self._refresh(ids)
|
|
399
|
+
self.push()
|
|
400
|
+
return web.json_response(self._kid_state_dict())
|
|
401
|
+
|
|
402
|
+
async def _set_playback(self, state: str) -> web.Response:
|
|
403
|
+
if not self._state["tv"]["reachable"]:
|
|
404
|
+
return web.json_response(self._kid_state_dict(), status=503)
|
|
405
|
+
if self._state.get("now_playing"):
|
|
406
|
+
self._state["now_playing"]["state"] = state
|
|
407
|
+
self.push()
|
|
408
|
+
return web.json_response(self._kid_state_dict())
|
|
409
|
+
|
|
410
|
+
async def _kid_pause(self, request: web.Request) -> web.Response:
|
|
411
|
+
return await self._set_playback("paused")
|
|
412
|
+
|
|
413
|
+
async def _kid_resume(self, request: web.Request) -> web.Response:
|
|
414
|
+
return await self._set_playback("playing")
|
|
415
|
+
|
|
416
|
+
async def _image(self, request: web.Request) -> web.Response:
|
|
417
|
+
return web.Response(body=_PLACEHOLDER_IMAGE, content_type="image/gif")
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
async def _serve(host: str, port: int, mock: MockTellybox) -> None:
|
|
421
|
+
url = await mock.start(host, port)
|
|
422
|
+
print(f"Mock Tellybox listening on {url}") # noqa: T201
|
|
423
|
+
try:
|
|
424
|
+
await asyncio.Event().wait()
|
|
425
|
+
finally:
|
|
426
|
+
await mock.stop()
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
def main(argv: list[str] | None = None) -> None:
|
|
430
|
+
parser = argparse.ArgumentParser(prog="python -m pytellybox.mock", description="Run a mock Tellybox server.")
|
|
431
|
+
parser.add_argument("--host", default="127.0.0.1")
|
|
432
|
+
parser.add_argument("--port", type=int, default=8099)
|
|
433
|
+
parser.add_argument("--token", default="tbx_dev", help="token with the control scope")
|
|
434
|
+
parser.add_argument("--read-token", action="append", default=[], help="token with the read scope only")
|
|
435
|
+
parser.add_argument("--keepalive", type=float, default=15.0, help="seconds between SSE keepalives")
|
|
436
|
+
args = parser.parse_args(argv)
|
|
437
|
+
mock = MockTellybox(token=args.token, read_tokens=args.read_token, keepalive_s=args.keepalive)
|
|
438
|
+
try:
|
|
439
|
+
asyncio.run(_serve(args.host, args.port, mock))
|
|
440
|
+
except KeyboardInterrupt:
|
|
441
|
+
pass
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
if __name__ == "__main__":
|
|
445
|
+
main()
|
pytellybox/models.py
ADDED
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
"""Typed views of the Tellybox API payloads.
|
|
2
|
+
|
|
3
|
+
Admin types follow Tellybox's `docs/admin-api.md` (API version 1); kid types follow `docs/kid-api.md`.
|
|
4
|
+
Every `from_dict` tolerates unknown extra keys (newer servers) and keeps the original payload in `raw`
|
|
5
|
+
where it matters, so callers can reach fields this version doesn't model yet.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from datetime import date, datetime
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
LAST_FIVE_S = 300
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _dt(value: str | None) -> datetime | None:
|
|
18
|
+
return datetime.fromisoformat(value) if value else None
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _date(value: str | None) -> date | None:
|
|
22
|
+
return date.fromisoformat(value) if value else None
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
# --------------------------------------------------------------------------- admin API
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass(frozen=True)
|
|
29
|
+
class Info:
|
|
30
|
+
"""`GET /api/info` (HA-6), no auth needed."""
|
|
31
|
+
|
|
32
|
+
instance_id: str
|
|
33
|
+
version: str
|
|
34
|
+
api: int
|
|
35
|
+
capabilities: tuple[str, ...]
|
|
36
|
+
|
|
37
|
+
@classmethod
|
|
38
|
+
def from_dict(cls, d: dict[str, Any]) -> Info:
|
|
39
|
+
return cls(d["instance_id"], d["version"], int(d["api"]), tuple(d.get("capabilities", ())))
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass(frozen=True)
|
|
43
|
+
class Day:
|
|
44
|
+
date: date | None # the timer day (WT-1); None before the first cast state
|
|
45
|
+
resets_at: datetime | None # the next daily reset
|
|
46
|
+
|
|
47
|
+
@classmethod
|
|
48
|
+
def from_dict(cls, d: dict[str, Any]) -> Day:
|
|
49
|
+
return cls(_date(d.get("date")), _dt(d.get("resets_at")))
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass(frozen=True)
|
|
53
|
+
class TV:
|
|
54
|
+
connection: str # the cast state's connection, or "unreachable" when the cast service is down
|
|
55
|
+
reachable: bool
|
|
56
|
+
device: str | None # the Chromecast's name
|
|
57
|
+
|
|
58
|
+
@classmethod
|
|
59
|
+
def from_dict(cls, d: dict[str, Any]) -> TV:
|
|
60
|
+
return cls(d["connection"], bool(d["reachable"]), d.get("device"))
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@dataclass(frozen=True)
|
|
64
|
+
class NowPlaying:
|
|
65
|
+
episode_id: int
|
|
66
|
+
show_id: int
|
|
67
|
+
title: str
|
|
68
|
+
show: str | None
|
|
69
|
+
state: str # loading | playing | paused | buffering
|
|
70
|
+
position_s: int | None
|
|
71
|
+
duration_s: int | None
|
|
72
|
+
profile_ids: tuple[int, ...]
|
|
73
|
+
|
|
74
|
+
@property
|
|
75
|
+
def thumb_path(self) -> str:
|
|
76
|
+
"""The episode thumbnail on the kid API (no auth): `/img/episode/{id}.jpg`."""
|
|
77
|
+
return f"/img/episode/{self.episode_id}.jpg"
|
|
78
|
+
|
|
79
|
+
@classmethod
|
|
80
|
+
def from_dict(cls, d: dict[str, Any]) -> NowPlaying:
|
|
81
|
+
return cls(
|
|
82
|
+
int(d["episode_id"]), int(d["show_id"]), d.get("title") or "", d.get("show"), d["state"],
|
|
83
|
+
d.get("position_s"), d.get("duration_s"), tuple(d.get("profile_ids") or ()),
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@dataclass(frozen=True)
|
|
88
|
+
class Group:
|
|
89
|
+
"""The current watchers, as the cast service's timer describes them."""
|
|
90
|
+
|
|
91
|
+
remaining_s: int | None # None = all unlimited (or unknown)
|
|
92
|
+
time_up: bool
|
|
93
|
+
last_five: bool
|
|
94
|
+
action: str | None # continue | finish_then_stop | stop_now
|
|
95
|
+
reason: str | None # allowance | session_max | blocked
|
|
96
|
+
grace_ends_at: datetime | None
|
|
97
|
+
session_started_at: datetime | None
|
|
98
|
+
session_elapsed_s: int | None
|
|
99
|
+
|
|
100
|
+
@classmethod
|
|
101
|
+
def from_dict(cls, d: dict[str, Any]) -> Group:
|
|
102
|
+
return cls(
|
|
103
|
+
d.get("remaining_s"), bool(d.get("time_up")), bool(d.get("last_five")), d.get("action"),
|
|
104
|
+
d.get("reason"), _dt(d.get("grace_ends_at")), _dt(d.get("session_started_at")),
|
|
105
|
+
d.get("session_elapsed_s"),
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
@dataclass(frozen=True)
|
|
110
|
+
class Profile:
|
|
111
|
+
"""One kid. Timer fields are None on a cold start, before Tellybox has seen the cast state."""
|
|
112
|
+
|
|
113
|
+
id: int
|
|
114
|
+
name: str
|
|
115
|
+
avatar: str | None
|
|
116
|
+
allowance_s: int
|
|
117
|
+
extra_s: int | None
|
|
118
|
+
used_s: int | None
|
|
119
|
+
remaining_s: int | None # None = unlimited today (when `unlimited`) or unknown
|
|
120
|
+
unlimited: bool
|
|
121
|
+
blocked: bool
|
|
122
|
+
mode: str # ignore_pauses | wall_clock
|
|
123
|
+
max_session_s: int | None
|
|
124
|
+
session_elapsed_s: int | None
|
|
125
|
+
can_start: bool | None
|
|
126
|
+
reason: str | None
|
|
127
|
+
watching: bool
|
|
128
|
+
last_five: bool
|
|
129
|
+
|
|
130
|
+
@property
|
|
131
|
+
def time_up(self) -> bool:
|
|
132
|
+
"""This kid may not start a pick now (out of time, blocked or past the session max)."""
|
|
133
|
+
return self.can_start is False
|
|
134
|
+
|
|
135
|
+
@classmethod
|
|
136
|
+
def from_dict(cls, d: dict[str, Any]) -> Profile:
|
|
137
|
+
return cls(
|
|
138
|
+
int(d["id"]), d["name"], d.get("avatar"), int(d["allowance_s"]), d.get("extra_s"), d.get("used_s"),
|
|
139
|
+
d.get("remaining_s"), bool(d.get("unlimited")), bool(d.get("blocked")), d.get("mode", "ignore_pauses"),
|
|
140
|
+
d.get("max_session_s"), d.get("session_elapsed_s"), d.get("can_start"), d.get("reason"),
|
|
141
|
+
bool(d.get("watching")), bool(d.get("last_five")),
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
@dataclass(frozen=True)
|
|
146
|
+
class Jobs:
|
|
147
|
+
queued: int
|
|
148
|
+
running: int
|
|
149
|
+
failed: int
|
|
150
|
+
held_ready: int # downloads waiting for approval
|
|
151
|
+
|
|
152
|
+
@classmethod
|
|
153
|
+
def from_dict(cls, d: dict[str, Any]) -> Jobs:
|
|
154
|
+
return cls(int(d.get("queued", 0)), int(d.get("running", 0)), int(d.get("failed", 0)),
|
|
155
|
+
int(d.get("held_ready", 0)))
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
@dataclass(frozen=True)
|
|
159
|
+
class Disk:
|
|
160
|
+
media_bytes: int
|
|
161
|
+
free_bytes: int | None
|
|
162
|
+
|
|
163
|
+
@classmethod
|
|
164
|
+
def from_dict(cls, d: dict[str, Any]) -> Disk:
|
|
165
|
+
return cls(int(d.get("media_bytes", 0)), d.get("free_bytes"))
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
@dataclass(frozen=True)
|
|
169
|
+
class AdminState:
|
|
170
|
+
"""`GET /api/admin/state`, each `/api/admin/events` event and every override response (HA-2)."""
|
|
171
|
+
|
|
172
|
+
instance_id: str
|
|
173
|
+
version: str
|
|
174
|
+
api: int
|
|
175
|
+
day: Day
|
|
176
|
+
tv: TV
|
|
177
|
+
now_playing: NowPlaying | None
|
|
178
|
+
group: Group
|
|
179
|
+
profiles: tuple[Profile, ...] # in the admin's order
|
|
180
|
+
jobs: Jobs
|
|
181
|
+
disk: Disk
|
|
182
|
+
raw: dict[str, Any] = field(repr=False, compare=False, default_factory=dict)
|
|
183
|
+
|
|
184
|
+
def profile(self, profile_id: int) -> Profile | None:
|
|
185
|
+
return next((p for p in self.profiles if p.id == profile_id), None)
|
|
186
|
+
|
|
187
|
+
@classmethod
|
|
188
|
+
def from_dict(cls, d: dict[str, Any]) -> AdminState:
|
|
189
|
+
np = d.get("now_playing")
|
|
190
|
+
return cls(
|
|
191
|
+
d["instance_id"], d["version"], int(d.get("api", 1)), Day.from_dict(d.get("day") or {}),
|
|
192
|
+
TV.from_dict(d["tv"]), NowPlaying.from_dict(np) if np else None, Group.from_dict(d.get("group") or {}),
|
|
193
|
+
tuple(Profile.from_dict(p) for p in d.get("profiles", ())), Jobs.from_dict(d.get("jobs") or {}),
|
|
194
|
+
Disk.from_dict(d.get("disk") or {}), d,
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
# --------------------------------------------------------------------------- kid API (browse and play)
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
@dataclass(frozen=True)
|
|
202
|
+
class KidProfile:
|
|
203
|
+
"""`GET /api/kid/profiles` item."""
|
|
204
|
+
|
|
205
|
+
profile_id: int
|
|
206
|
+
name: str
|
|
207
|
+
picture: str | None # path of the uploaded photo, e.g. /img/profile/1.jpg
|
|
208
|
+
avatar: str | None # built-in avatar key: /static/avatars/{avatar}.svg
|
|
209
|
+
|
|
210
|
+
@classmethod
|
|
211
|
+
def from_dict(cls, d: dict[str, Any]) -> KidProfile:
|
|
212
|
+
return cls(int(d["profile_id"]), d["name"], d.get("picture"), d.get("avatar"))
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
@dataclass(frozen=True)
|
|
216
|
+
class Tile:
|
|
217
|
+
episode_id: int
|
|
218
|
+
show_id: int
|
|
219
|
+
thumb: str # path, e.g. /img/episode/4.jpg
|
|
220
|
+
title: str
|
|
221
|
+
progress: float | None
|
|
222
|
+
finished: bool
|
|
223
|
+
kind: str | None = None # resume | next (continue watching only)
|
|
224
|
+
|
|
225
|
+
@classmethod
|
|
226
|
+
def from_dict(cls, d: dict[str, Any]) -> Tile:
|
|
227
|
+
return cls(int(d["episode_id"]), int(d["show_id"]), d["thumb"], d.get("title") or "", d.get("progress"),
|
|
228
|
+
bool(d.get("finished")), d.get("kind"))
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
@dataclass(frozen=True)
|
|
232
|
+
class ShowRef:
|
|
233
|
+
show_id: int
|
|
234
|
+
artwork: str # path, e.g. /img/show/2.jpg
|
|
235
|
+
title: str
|
|
236
|
+
|
|
237
|
+
@classmethod
|
|
238
|
+
def from_dict(cls, d: dict[str, Any]) -> ShowRef:
|
|
239
|
+
return cls(int(d["show_id"]), d["artwork"], d.get("title") or "")
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
@dataclass(frozen=True)
|
|
243
|
+
class Home:
|
|
244
|
+
"""`GET /api/kid/home`."""
|
|
245
|
+
|
|
246
|
+
continue_watching: tuple[Tile, ...]
|
|
247
|
+
shows: tuple[ShowRef, ...]
|
|
248
|
+
|
|
249
|
+
@classmethod
|
|
250
|
+
def from_dict(cls, d: dict[str, Any]) -> Home:
|
|
251
|
+
return cls(tuple(Tile.from_dict(t) for t in d.get("continue", ())),
|
|
252
|
+
tuple(ShowRef.from_dict(s) for s in d.get("shows", ())))
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
@dataclass(frozen=True)
|
|
256
|
+
class Show:
|
|
257
|
+
"""`GET /api/kid/shows/{id}`."""
|
|
258
|
+
|
|
259
|
+
show_id: int
|
|
260
|
+
artwork: str
|
|
261
|
+
title: str
|
|
262
|
+
episodes: tuple[Tile, ...]
|
|
263
|
+
|
|
264
|
+
@classmethod
|
|
265
|
+
def from_dict(cls, d: dict[str, Any]) -> Show:
|
|
266
|
+
return cls(int(d["show_id"]), d["artwork"], d.get("title") or "",
|
|
267
|
+
tuple(Tile.from_dict(t) for t in d.get("episodes", ())))
|
pytellybox/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: pytellybox
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Async client for the Tellybox admin and kid APIs (used by the Home Assistant integration)
|
|
5
|
+
Project-URL: Homepage, https://github.com/sandermvanvliet/pytellybox
|
|
6
|
+
Project-URL: Tellybox, https://github.com/sandermvanvliet/Tellybox
|
|
7
|
+
Author: Sander van Vliet
|
|
8
|
+
License-Expression: Apache-2.0
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: Framework :: AsyncIO
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Topic :: Home Automation
|
|
14
|
+
Classifier: Typing :: Typed
|
|
15
|
+
Requires-Python: >=3.13
|
|
16
|
+
Requires-Dist: aiohttp>=3.10
|
|
17
|
+
Provides-Extra: test
|
|
18
|
+
Requires-Dist: aioresponses>=0.7; extra == 'test'
|
|
19
|
+
Requires-Dist: pytest-asyncio>=0.24; extra == 'test'
|
|
20
|
+
Requires-Dist: pytest>=8; extra == 'test'
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
|
|
23
|
+
# pytellybox
|
|
24
|
+
|
|
25
|
+
An async Python client for [Tellybox](https://github.com/sandermvanvliet/Tellybox), the self-hosted app that lets young kids pick parent-approved videos for the TV within a daily time allowance. It is the library behind the [Home Assistant integration](https://github.com/sandermvanvliet/ha-tellybox).
|
|
26
|
+
|
|
27
|
+
- **Admin API** (bearer token from Tellybox's *Integrations* page): live state and its event stream, and the parent overrides (extra time, unlimited today, block today, stop now, clear today) for everyone or for chosen kids.
|
|
28
|
+
- **Kid API** (no token): browse shows and episodes, play, pause and resume. A play is refused when someone is out of time, because Tellybox's timer always decides.
|
|
29
|
+
|
|
30
|
+
```python
|
|
31
|
+
import aiohttp
|
|
32
|
+
from pytellybox import TellyboxClient
|
|
33
|
+
|
|
34
|
+
async with aiohttp.ClientSession() as session:
|
|
35
|
+
tellybox = TellyboxClient("https://tellybox.example", "tbx_…", session)
|
|
36
|
+
state = await tellybox.state()
|
|
37
|
+
for kid in state.profiles:
|
|
38
|
+
print(kid.name, kid.remaining_s)
|
|
39
|
+
await tellybox.add_time(15, profile_ids=[1])
|
|
40
|
+
async for state in tellybox.events(): # the current state first, then one per change
|
|
41
|
+
print(state.group.remaining_s)
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
For development without a Tellybox, run the mock server: `python -m pytellybox.mock --port 8099 --token tbx_dev` (add `--read-token tbx_ro` for a read-only token). Tests can use `MockTellybox` from `pytellybox.mock` with aiohttp's `TestServer`.
|
|
45
|
+
|
|
46
|
+
The API contract is Tellybox's [`docs/admin-api.md`](https://github.com/sandermvanvliet/Tellybox/blob/main/docs/admin-api.md) and [`docs/kid-api.md`](https://github.com/sandermvanvliet/Tellybox/blob/main/docs/kid-api.md). Tellybox is for the LAN and Tailscale only; use HTTPS behind your reverse proxy, and keep tokens out of logs.
|
|
47
|
+
|
|
48
|
+
## Development
|
|
49
|
+
|
|
50
|
+
```sh
|
|
51
|
+
python3 -m venv .venv && .venv/bin/pip install -e '.[test]'
|
|
52
|
+
.venv/bin/python -m pytest -q
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Releases are published to PyPI by GitHub Actions when a `v*` tag is pushed (trusted publishing).
|
|
56
|
+
|
|
57
|
+
Licensed under Apache-2.0.
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
pytellybox/__init__.py,sha256=v2oUAgGnBdVCXWrMlyS1zkV0OiwkT-CcsAiR0oEXR48,1008
|
|
2
|
+
pytellybox/admin_state.json,sha256=DyunpjBoi4iEVIT6Ktr7SgmLrgCUJPALxAd1M5EPGRY,1454
|
|
3
|
+
pytellybox/client.py,sha256=ZyPPTg2-iMqVTG6TJma-BPBJtiNH9gdBk7xZdwmJ0Oo,10642
|
|
4
|
+
pytellybox/errors.py,sha256=hLe23ExMOzY32qXn7ZpWk89lv4GbTObNsQKbRNGCZr4,1159
|
|
5
|
+
pytellybox/mock.py,sha256=O3dGPkQSQUA-SQpvZYRuWHyqlLLPSrJfkvnNPg5neu4,20462
|
|
6
|
+
pytellybox/models.py,sha256=tg4gD4Tl0a-i9EDqKM0uXtsZ88iz9k26zwdz9osuqMo,8281
|
|
7
|
+
pytellybox/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
8
|
+
pytellybox-0.1.0.dist-info/METADATA,sha256=LLb4cJcauQDCUaQ57evi-KzuMRIetXv63pUtdf0ZSGA,2825
|
|
9
|
+
pytellybox-0.1.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
10
|
+
pytellybox-0.1.0.dist-info/licenses/LICENSE,sha256=_DMpHboiA8TTPB3jrnYlmyWoJFhBCXXoYXKnGmQyiI8,11347
|
|
11
|
+
pytellybox-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
Apache License
|
|
2
|
+
Version 2.0, January 2004
|
|
3
|
+
http://www.apache.org/licenses/
|
|
4
|
+
|
|
5
|
+
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
|
6
|
+
|
|
7
|
+
1. Definitions.
|
|
8
|
+
|
|
9
|
+
"License" shall mean the terms and conditions for use, reproduction,
|
|
10
|
+
and distribution as defined by Sections 1 through 9 of this document.
|
|
11
|
+
|
|
12
|
+
"Licensor" shall mean the copyright owner or entity authorized by
|
|
13
|
+
the copyright owner that is granting the License.
|
|
14
|
+
|
|
15
|
+
"Legal Entity" shall mean the union of the acting entity and all
|
|
16
|
+
other entities that control, are controlled by, or are under common
|
|
17
|
+
control with that entity. For the purposes of this definition,
|
|
18
|
+
"control" means (i) the power, direct or indirect, to cause the
|
|
19
|
+
direction or management of such entity, whether by contract or
|
|
20
|
+
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
|
21
|
+
outstanding shares, or (iii) beneficial ownership of such entity.
|
|
22
|
+
|
|
23
|
+
"You" (or "Your") shall mean an individual or Legal Entity
|
|
24
|
+
exercising permissions granted by this License.
|
|
25
|
+
|
|
26
|
+
"Source" form shall mean the preferred form for making modifications,
|
|
27
|
+
including but not limited to software source code, documentation
|
|
28
|
+
source, and configuration files.
|
|
29
|
+
|
|
30
|
+
"Object" form shall mean any form resulting from mechanical
|
|
31
|
+
transformation or translation of a Source form, including but
|
|
32
|
+
not limited to compiled object code, generated documentation,
|
|
33
|
+
and conversions to other media types.
|
|
34
|
+
|
|
35
|
+
"Work" shall mean the work of authorship, whether in Source or
|
|
36
|
+
Object form, made available under the License, as indicated by a
|
|
37
|
+
copyright notice that is included in or attached to the work
|
|
38
|
+
(an example is provided in the Appendix below).
|
|
39
|
+
|
|
40
|
+
"Derivative Works" shall mean any work, whether in Source or Object
|
|
41
|
+
form, that is based on (or derived from) the Work and for which the
|
|
42
|
+
editorial revisions, annotations, elaborations, or other modifications
|
|
43
|
+
represent, as a whole, an original work of authorship. For the purposes
|
|
44
|
+
of this License, Derivative Works shall not include works that remain
|
|
45
|
+
separable from, or merely link (or bind by name) to the interfaces of,
|
|
46
|
+
the Work and Derivative Works thereof.
|
|
47
|
+
|
|
48
|
+
"Contribution" shall mean any work of authorship, including
|
|
49
|
+
the original version of the Work and any modifications or additions
|
|
50
|
+
to that Work or Derivative Works thereof, that is intentionally
|
|
51
|
+
submitted to Licensor for inclusion in the Work by the copyright owner
|
|
52
|
+
or by an individual or Legal Entity authorized to submit on behalf of
|
|
53
|
+
the copyright owner. For the purposes of this definition, "submitted"
|
|
54
|
+
means any form of electronic, verbal, or written communication sent
|
|
55
|
+
to the Licensor or its representatives, including but not limited to
|
|
56
|
+
communication on electronic mailing lists, source code control systems,
|
|
57
|
+
and issue tracking systems that are managed by, or on behalf of, the
|
|
58
|
+
Licensor for the purpose of discussing and improving the Work, but
|
|
59
|
+
excluding communication that is conspicuously marked or otherwise
|
|
60
|
+
designated in writing by the copyright owner as "Not a Contribution."
|
|
61
|
+
|
|
62
|
+
"Contributor" shall mean Licensor and any individual or Legal Entity
|
|
63
|
+
on behalf of whom a Contribution has been received by Licensor and
|
|
64
|
+
subsequently incorporated within the Work.
|
|
65
|
+
|
|
66
|
+
2. Grant of Copyright License. Subject to the terms and conditions of
|
|
67
|
+
this License, each Contributor hereby grants to You a perpetual,
|
|
68
|
+
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
|
69
|
+
copyright license to reproduce, prepare Derivative Works of,
|
|
70
|
+
publicly display, publicly perform, sublicense, and distribute the
|
|
71
|
+
Work and such Derivative Works in Source or Object form.
|
|
72
|
+
|
|
73
|
+
3. Grant of Patent License. Subject to the terms and conditions of
|
|
74
|
+
this License, each Contributor hereby grants to You a perpetual,
|
|
75
|
+
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
|
76
|
+
(except as stated in this section) patent license to make, have made,
|
|
77
|
+
use, offer to sell, sell, import, and otherwise transfer the Work,
|
|
78
|
+
where such license applies only to those patent claims licensable
|
|
79
|
+
by such Contributor that are necessarily infringed by their
|
|
80
|
+
Contribution(s) alone or by combination of their Contribution(s)
|
|
81
|
+
with the Work to which such Contribution(s) was submitted. If You
|
|
82
|
+
institute patent litigation against any entity (including a
|
|
83
|
+
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
|
84
|
+
or a Contribution incorporated within the Work constitutes direct
|
|
85
|
+
or contributory patent infringement, then any patent licenses
|
|
86
|
+
granted to You under this License for that Work shall terminate
|
|
87
|
+
as of the date such litigation is filed.
|
|
88
|
+
|
|
89
|
+
4. Redistribution. You may reproduce and distribute copies of the
|
|
90
|
+
Work or Derivative Works thereof in any medium, with or without
|
|
91
|
+
modifications, and in Source or Object form, provided that You
|
|
92
|
+
meet the following conditions:
|
|
93
|
+
|
|
94
|
+
(a) You must give any other recipients of the Work or
|
|
95
|
+
Derivative Works a copy of this License; and
|
|
96
|
+
|
|
97
|
+
(b) You must cause any modified files to carry prominent notices
|
|
98
|
+
stating that You changed the files; and
|
|
99
|
+
|
|
100
|
+
(c) You must retain, in the Source form of any Derivative Works
|
|
101
|
+
that You distribute, all copyright, patent, trademark, and
|
|
102
|
+
attribution notices from the Source form of the Work,
|
|
103
|
+
excluding those notices that do not pertain to any part of
|
|
104
|
+
the Derivative Works; and
|
|
105
|
+
|
|
106
|
+
(d) If the Work includes a "NOTICE" text file as part of its
|
|
107
|
+
distribution, then any Derivative Works that You distribute must
|
|
108
|
+
include a readable copy of the attribution notices contained
|
|
109
|
+
within such NOTICE file, excluding those notices that do not
|
|
110
|
+
pertain to any part of the Derivative Works, in at least one
|
|
111
|
+
of the following places: within a NOTICE text file distributed
|
|
112
|
+
as part of the Derivative Works; within the Source form or
|
|
113
|
+
documentation, if provided along with the Derivative Works; or,
|
|
114
|
+
within a display generated by the Derivative Works, if and
|
|
115
|
+
wherever such third-party notices normally appear. The contents
|
|
116
|
+
of the NOTICE file are for informational purposes only and
|
|
117
|
+
do not modify the License. You may add Your own attribution
|
|
118
|
+
notices within Derivative Works that You distribute, alongside
|
|
119
|
+
or as an addendum to the NOTICE text from the Work, provided
|
|
120
|
+
that such additional attribution notices cannot be construed
|
|
121
|
+
as modifying the License.
|
|
122
|
+
|
|
123
|
+
You may add Your own copyright statement to Your modifications and
|
|
124
|
+
may provide additional or different license terms and conditions
|
|
125
|
+
for use, reproduction, or distribution of Your modifications, or
|
|
126
|
+
for any such Derivative Works as a whole, provided Your use,
|
|
127
|
+
reproduction, and distribution of the Work otherwise complies with
|
|
128
|
+
the conditions stated in this License.
|
|
129
|
+
|
|
130
|
+
5. Submission of Contributions. Unless You explicitly state otherwise,
|
|
131
|
+
any Contribution intentionally submitted for inclusion in the Work
|
|
132
|
+
by You to the Licensor shall be under the terms and conditions of
|
|
133
|
+
this License, without any additional terms or conditions.
|
|
134
|
+
Notwithstanding the above, nothing herein shall supersede or modify
|
|
135
|
+
the terms of any separate license agreement you may have executed
|
|
136
|
+
with Licensor regarding such Contributions.
|
|
137
|
+
|
|
138
|
+
6. Trademarks. This License does not grant permission to use the trade
|
|
139
|
+
names, trademarks, service marks, or product names of the Licensor,
|
|
140
|
+
except as required for reasonable and customary use in describing the
|
|
141
|
+
origin of the Work and reproducing the content of the NOTICE file.
|
|
142
|
+
|
|
143
|
+
7. Disclaimer of Warranty. Unless required by applicable law or
|
|
144
|
+
agreed to in writing, Licensor provides the Work (and each
|
|
145
|
+
Contributor provides its Contributions) on an "AS IS" BASIS,
|
|
146
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
|
147
|
+
implied, including, without limitation, any warranties or conditions
|
|
148
|
+
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
|
149
|
+
PARTICULAR PURPOSE. You are solely responsible for determining the
|
|
150
|
+
appropriateness of using or redistributing the Work and assume any
|
|
151
|
+
risks associated with Your exercise of permissions under this License.
|
|
152
|
+
|
|
153
|
+
8. Limitation of Liability. In no event and under no legal theory,
|
|
154
|
+
whether in tort (including negligence), contract, or otherwise,
|
|
155
|
+
unless required by applicable law (such as deliberate and grossly
|
|
156
|
+
negligent acts) or agreed to in writing, shall any Contributor be
|
|
157
|
+
liable to You for damages, including any direct, indirect, special,
|
|
158
|
+
incidental, or consequential damages of any character arising as a
|
|
159
|
+
result of this License or out of the use or inability to use the
|
|
160
|
+
Work (including but not limited to damages for loss of goodwill,
|
|
161
|
+
work stoppage, computer failure or malfunction, or any and all
|
|
162
|
+
other commercial damages or losses), even if such Contributor
|
|
163
|
+
has been advised of the possibility of such damages.
|
|
164
|
+
|
|
165
|
+
9. Accepting Warranty or Additional Liability. While redistributing
|
|
166
|
+
the Work or Derivative Works thereof, You may choose to offer,
|
|
167
|
+
and charge a fee for, acceptance of support, warranty, indemnity,
|
|
168
|
+
or other liability obligations and/or rights consistent with this
|
|
169
|
+
License. However, in accepting such obligations, You may act only
|
|
170
|
+
on Your own behalf and on Your sole responsibility, not on behalf
|
|
171
|
+
of any other Contributor, and only if You agree to indemnify,
|
|
172
|
+
defend, and hold each Contributor harmless for any liability
|
|
173
|
+
incurred by, or claims asserted against, such Contributor by reason
|
|
174
|
+
of your accepting any such warranty or additional liability.
|
|
175
|
+
|
|
176
|
+
END OF TERMS AND CONDITIONS
|
|
177
|
+
|
|
178
|
+
APPENDIX: How to apply the Apache License to your work.
|
|
179
|
+
|
|
180
|
+
To apply the Apache License to your work, attach the following
|
|
181
|
+
boilerplate notice, with the fields enclosed by brackets "[]"
|
|
182
|
+
replaced with your own identifying information. (Don't include
|
|
183
|
+
the brackets!) The text should be enclosed in the appropriate
|
|
184
|
+
comment syntax for the file format. We also recommend that a
|
|
185
|
+
file or class name and description of purpose be included on the
|
|
186
|
+
same "printed page" as the copyright notice for easier
|
|
187
|
+
identification within third-party archives.
|
|
188
|
+
|
|
189
|
+
Copyright 2026 Sander van Vliet
|
|
190
|
+
|
|
191
|
+
Licensed under the Apache License, Version 2.0 (the "License");
|
|
192
|
+
you may not use this file except in compliance with the License.
|
|
193
|
+
You may obtain a copy of the License at
|
|
194
|
+
|
|
195
|
+
http://www.apache.org/licenses/LICENSE-2.0
|
|
196
|
+
|
|
197
|
+
Unless required by applicable law or agreed to in writing, software
|
|
198
|
+
distributed under the License is distributed on an "AS IS" BASIS,
|
|
199
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
200
|
+
See the License for the specific language governing permissions and
|
|
201
|
+
limitations under the License.
|
|
202
|
+
|