videorouter-live 0.2.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,23 @@
1
+ """videorouter-live — Python SDK for videorouter.sh live video & world models (https://videorouter.sh/docs/live-video).
2
+
3
+ The SDK is a client for the session BROKER: it opens a session, keeps it alive and settles it. It never carries media —
4
+ `session.connect` holds the host's short-lived credentials, which you hand to the host's own SDK in the browser.
5
+ """
6
+
7
+ from ._client import AsyncLiveVideo, DEFAULT_BASE_URL, LiveVideo
8
+ from ._errors import (AuthenticationError, ConcurrencyLimitError, HostCapacityError, InsufficientCreditsError,
9
+ InvalidRequestError, LiveVideoError, NotFoundError, PermissionDeniedError,
10
+ PricingUnavailableError, RateLimitError, SessionStateError, TimeoutError, UpstreamError,
11
+ VideoGenerationError)
12
+ from ._session import AsyncLiveSession, LiveSession
13
+ from ._types import Connect, Model, Status, Token
14
+ from ._videos import AsyncVideoJob, AsyncVideos, VideoJob, Videos
15
+
16
+ __version__ = "0.2.0"
17
+ __all__ = [
18
+ "LiveVideo", "AsyncLiveVideo", "LiveSession", "AsyncLiveSession", "Model", "Token", "Status", "Connect",
19
+ "DEFAULT_BASE_URL", "LiveVideoError", "AuthenticationError", "PermissionDeniedError", "InvalidRequestError",
20
+ "InsufficientCreditsError", "PricingUnavailableError", "NotFoundError", "RateLimitError",
21
+ "ConcurrencyLimitError", "HostCapacityError", "UpstreamError", "SessionStateError", "TimeoutError",
22
+ "VideoGenerationError", "Videos", "AsyncVideos", "VideoJob", "AsyncVideoJob",
23
+ ]
@@ -0,0 +1,250 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import os
5
+ import time
6
+ from typing import Any, Optional
7
+
8
+ import httpx
9
+
10
+ from ._errors import LiveVideoError, UpstreamError, error_from_response
11
+ from ._session import AsyncLiveSession, LiveSession
12
+ from ._types import Model, Status, Token
13
+ from ._videos import AsyncVideos, Videos
14
+
15
+ DEFAULT_BASE_URL = "https://videorouter.sh/api/v1"
16
+ _RETRY_STATUSES = (502, 503, 504)
17
+
18
+
19
+ def _resolve_auth(api_key: Optional[str], token: Optional[str]) -> str:
20
+ secret = token or api_key or os.environ.get("VIDEOROUTER_API_KEY")
21
+ if not secret:
22
+ raise ValueError("pass api_key=... (server) or token=... (browser-safe, from create_token), "
23
+ "or set VIDEOROUTER_API_KEY")
24
+ return secret
25
+
26
+
27
+ def _parse(resp: httpx.Response) -> Any:
28
+ try:
29
+ body = resp.json()
30
+ except ValueError:
31
+ body = resp.text
32
+ if resp.status_code >= 400:
33
+ raise error_from_response(resp.status_code, body, resp.headers)
34
+ return body
35
+
36
+
37
+ def _session_body(model: str, max_duration_seconds: Optional[int], options: Optional[dict]) -> dict:
38
+ body: dict[str, Any] = {"model": model}
39
+ if max_duration_seconds is not None:
40
+ body["max_duration_seconds"] = max_duration_seconds
41
+ if options:
42
+ body["options"] = options
43
+ return body
44
+
45
+
46
+ def _token_body(models, expires_in, max_sessions, max_session_duration_seconds) -> dict:
47
+ body: dict[str, Any] = {}
48
+ if models is not None:
49
+ body["models"] = list(models)
50
+ if expires_in is not None:
51
+ body["expires_in"] = expires_in
52
+ if max_sessions is not None:
53
+ body["max_sessions"] = max_sessions
54
+ if max_session_duration_seconds is not None:
55
+ body["max_session_duration_seconds"] = max_session_duration_seconds
56
+ return body
57
+
58
+
59
+ class LiveVideo:
60
+ """Synchronous client.
61
+
62
+ lv = LiveVideo(api_key="llmr_sk_...")
63
+ with lv.session("fal/happy-oyster-adventure", max_duration_seconds=60,
64
+ options={"world": {"prompt": "a rainy city", "perspective": "first_person"}}) as s:
65
+ hand_to_browser(s.connect) # host credentials, returned once
66
+ ... # heartbeats run in the background
67
+ print(s.billed_seconds, s.cost_usd) # settled on exit
68
+ """
69
+
70
+ def __init__(self, api_key: Optional[str] = None, *, token: Optional[str] = None, base_url: str = DEFAULT_BASE_URL,
71
+ timeout: float = 30.0, max_retries: int = 2, http_client: Optional[httpx.Client] = None) -> None:
72
+ self._auth = _resolve_auth(api_key, token)
73
+ self.base_url = base_url.rstrip("/")
74
+ self.max_retries = max_retries
75
+ self._owns = http_client is None
76
+ self._http = http_client or httpx.Client(timeout=timeout)
77
+ self.videos = Videos(self) # the /v1/videos generation API: lv.videos.generate({...})
78
+
79
+ # ── transport ────────────────────────────────────────────────────────────────────────────────────────────
80
+ def _request(self, method: str, path: str, json: Any = None, retry: bool = False) -> Any:
81
+ attempts = (self.max_retries if retry else 0) + 1
82
+ last: Optional[LiveVideoError] = None
83
+ for i in range(attempts):
84
+ try:
85
+ resp = self._http.request(method, self.base_url + path, json=json,
86
+ headers={"Authorization": f"Bearer {self._auth}"})
87
+ if resp.status_code in _RETRY_STATUSES and i + 1 < attempts:
88
+ time.sleep(min(0.5 * 2 ** i, 4))
89
+ continue
90
+ return _parse(resp)
91
+ except httpx.TransportError as exc:
92
+ last = UpstreamError(f"network error: {exc}")
93
+ if i + 1 >= attempts:
94
+ raise last from exc
95
+ time.sleep(min(0.5 * 2 ** i, 4))
96
+ raise last or UpstreamError("request failed")
97
+
98
+ # ── API ──────────────────────────────────────────────────────────────────────────────────────────────────
99
+ def models(self) -> list:
100
+ return [Model.from_dict(m) for m in self._request("GET", "/live-video/models", retry=True)["data"]]
101
+
102
+ def create_token(self, *, models: Optional[list] = None, expires_in: Optional[int] = None,
103
+ max_sessions: Optional[int] = None, max_session_duration_seconds: Optional[int] = None) -> Token:
104
+ """Mint a short-lived, live-video-only credential for a browser. Call it from your server."""
105
+ return Token.from_dict(self._request("POST", "/live-video/tokens", json=_token_body(
106
+ models, expires_in, max_sessions, max_session_duration_seconds)))
107
+
108
+ def create_session(self, model: str, *, max_duration_seconds: Optional[int] = None,
109
+ options: Optional[dict] = None) -> LiveSession:
110
+ return LiveSession(self, self._request("POST", "/live-video/sessions",
111
+ json=_session_body(model, max_duration_seconds, options)))
112
+
113
+ def get_session(self, session_id: str) -> LiveSession:
114
+ return LiveSession(self, self._request("GET", f"/live-video/sessions/{session_id}", retry=True))
115
+
116
+ def session(self, model: str, *, max_duration_seconds: Optional[int] = None, options: Optional[dict] = None,
117
+ wait_until_ready: bool = True, auto_heartbeat: bool = True, ready_timeout: float = 300.0,
118
+ poll_interval: float = 4.0, heartbeat_interval: Optional[float] = None) -> "_SessionContext":
119
+ """Context manager: opens the session, waits until it is ready, heartbeats in the background, and settles it on
120
+ exit (even on error) — the common case in one `with`."""
121
+ return _SessionContext(self, model, max_duration_seconds, options, wait_until_ready, auto_heartbeat,
122
+ ready_timeout, heartbeat_interval, poll_interval)
123
+
124
+ def close(self) -> None:
125
+ if self._owns:
126
+ self._http.close()
127
+
128
+ def __enter__(self) -> "LiveVideo":
129
+ return self
130
+
131
+ def __exit__(self, *exc: Any) -> None:
132
+ self.close()
133
+
134
+
135
+ class _SessionContext:
136
+ def __init__(self, client: LiveVideo, model, max_duration_seconds, options, wait, beat, timeout, interval,
137
+ poll) -> None:
138
+ self._a = (client, model, max_duration_seconds, options, wait, beat, timeout, interval, poll)
139
+ self._s: Optional[LiveSession] = None
140
+
141
+ def __enter__(self) -> LiveSession:
142
+ client, model, dur, options, wait, beat, timeout, interval, poll = self._a
143
+ s = client.create_session(model, max_duration_seconds=dur, options=options)
144
+ self._s = s
145
+ try:
146
+ if wait:
147
+ s.wait_until_ready(timeout=timeout, poll_interval=poll)
148
+ if beat:
149
+ s.start_heartbeat(interval)
150
+ except BaseException:
151
+ s.end()
152
+ raise
153
+ return s
154
+
155
+ def __exit__(self, *exc: Any) -> None:
156
+ if self._s is not None:
157
+ self._s.end()
158
+
159
+
160
+ class AsyncLiveVideo:
161
+ """Asyncio client — same surface as `LiveVideo`, every network method is a coroutine.
162
+
163
+ async with AsyncLiveVideo(api_key="llmr_sk_...") as lv:
164
+ async with lv.session("decart/lucy-2.5", max_duration_seconds=120) as s:
165
+ hand_to_browser(s.connect)
166
+ """
167
+
168
+ def __init__(self, api_key: Optional[str] = None, *, token: Optional[str] = None, base_url: str = DEFAULT_BASE_URL,
169
+ timeout: float = 30.0, max_retries: int = 2, http_client: Optional[httpx.AsyncClient] = None) -> None:
170
+ self._auth = _resolve_auth(api_key, token)
171
+ self.base_url = base_url.rstrip("/")
172
+ self.max_retries = max_retries
173
+ self._owns = http_client is None
174
+ self._http = http_client or httpx.AsyncClient(timeout=timeout)
175
+ self.videos = AsyncVideos(self) # the /v1/videos generation API: await lv.videos.generate({...})
176
+
177
+ async def _request(self, method: str, path: str, json: Any = None, retry: bool = False) -> Any:
178
+ attempts = (self.max_retries if retry else 0) + 1
179
+ last: Optional[LiveVideoError] = None
180
+ for i in range(attempts):
181
+ try:
182
+ resp = await self._http.request(method, self.base_url + path, json=json,
183
+ headers={"Authorization": f"Bearer {self._auth}"})
184
+ if resp.status_code in _RETRY_STATUSES and i + 1 < attempts:
185
+ await asyncio.sleep(min(0.5 * 2 ** i, 4))
186
+ continue
187
+ return _parse(resp)
188
+ except httpx.TransportError as exc:
189
+ last = UpstreamError(f"network error: {exc}")
190
+ if i + 1 >= attempts:
191
+ raise last from exc
192
+ await asyncio.sleep(min(0.5 * 2 ** i, 4))
193
+ raise last or UpstreamError("request failed")
194
+
195
+ async def models(self) -> list:
196
+ return [Model.from_dict(m) for m in (await self._request("GET", "/live-video/models", retry=True))["data"]]
197
+
198
+ async def create_token(self, *, models: Optional[list] = None, expires_in: Optional[int] = None,
199
+ max_sessions: Optional[int] = None, max_session_duration_seconds: Optional[int] = None) -> Token:
200
+ return Token.from_dict(await self._request("POST", "/live-video/tokens", json=_token_body(
201
+ models, expires_in, max_sessions, max_session_duration_seconds)))
202
+
203
+ async def create_session(self, model: str, *, max_duration_seconds: Optional[int] = None,
204
+ options: Optional[dict] = None) -> AsyncLiveSession:
205
+ return AsyncLiveSession(self, await self._request("POST", "/live-video/sessions",
206
+ json=_session_body(model, max_duration_seconds, options)))
207
+
208
+ async def get_session(self, session_id: str) -> AsyncLiveSession:
209
+ return AsyncLiveSession(self, await self._request("GET", f"/live-video/sessions/{session_id}", retry=True))
210
+
211
+ def session(self, model: str, *, max_duration_seconds: Optional[int] = None, options: Optional[dict] = None,
212
+ wait_until_ready: bool = True, auto_heartbeat: bool = True, ready_timeout: float = 300.0,
213
+ poll_interval: float = 4.0, heartbeat_interval: Optional[float] = None) -> "_AsyncSessionContext":
214
+ return _AsyncSessionContext(self, model, max_duration_seconds, options, wait_until_ready, auto_heartbeat,
215
+ ready_timeout, heartbeat_interval, poll_interval)
216
+
217
+ async def aclose(self) -> None:
218
+ if self._owns:
219
+ await self._http.aclose()
220
+
221
+ async def __aenter__(self) -> "AsyncLiveVideo":
222
+ return self
223
+
224
+ async def __aexit__(self, *exc: Any) -> None:
225
+ await self.aclose()
226
+
227
+
228
+ class _AsyncSessionContext:
229
+ def __init__(self, client: AsyncLiveVideo, model, max_duration_seconds, options, wait, beat, timeout, interval,
230
+ poll) -> None:
231
+ self._a = (client, model, max_duration_seconds, options, wait, beat, timeout, interval, poll)
232
+ self._s: Optional[AsyncLiveSession] = None
233
+
234
+ async def __aenter__(self) -> AsyncLiveSession:
235
+ client, model, dur, options, wait, beat, timeout, interval, poll = self._a
236
+ s = await client.create_session(model, max_duration_seconds=dur, options=options)
237
+ self._s = s
238
+ try:
239
+ if wait:
240
+ await s.wait_until_ready(timeout=timeout, poll_interval=poll)
241
+ if beat:
242
+ s.start_heartbeat(interval)
243
+ except BaseException:
244
+ await s.end()
245
+ raise
246
+ return s
247
+
248
+ async def __aexit__(self, *exc: Any) -> None:
249
+ if self._s is not None:
250
+ await self._s.end()
@@ -0,0 +1,107 @@
1
+ """Typed errors. Every failed API call raises a `LiveVideoError` subclass carrying the HTTP `status`, the API's own
2
+ error `code`/`type`/`message`, and `retry_after` when the server sent one — match on the class, not on strings."""
3
+
4
+ from __future__ import annotations
5
+
6
+ from typing import Any, Mapping, Optional
7
+
8
+
9
+ class LiveVideoError(Exception):
10
+ def __init__(self, message: str, *, status: Optional[int] = None, code: Optional[str] = None,
11
+ type: Optional[str] = None, retry_after: Optional[float] = None, body: Any = None):
12
+ super().__init__(message)
13
+ self.message, self.status, self.code, self.type = message, status, code, type
14
+ self.retry_after, self.body = retry_after, body
15
+
16
+ def __str__(self) -> str:
17
+ bits = [self.message]
18
+ if self.status is not None:
19
+ bits.append(f"(HTTP {self.status}{', ' + self.code if self.code else ''})")
20
+ return " ".join(bits)
21
+
22
+
23
+ class AuthenticationError(LiveVideoError):
24
+ """401 — missing, invalid or expired key/token."""
25
+
26
+
27
+ class PermissionDeniedError(LiveVideoError):
28
+ """403 — missing `live_video` scope, a model/token restriction, or a token used outside live-video."""
29
+
30
+
31
+ class InvalidRequestError(LiveVideoError):
32
+ """400 — a bad parameter (unknown model, duration above the host cap, unsupported option...)."""
33
+
34
+
35
+ class InsufficientCreditsError(LiveVideoError):
36
+ """402 — the credit hold for this session isn't covered (or a spend cap was hit)."""
37
+
38
+
39
+ class PricingUnavailableError(LiveVideoError):
40
+ """502 `pricing_unavailable` — the model has no verified price yet and can't be booked."""
41
+
42
+
43
+ class NotFoundError(LiveVideoError):
44
+ """404 — no such session, or it belongs to another organization."""
45
+
46
+
47
+ class RateLimitError(LiveVideoError):
48
+ """429 — too many requests."""
49
+
50
+
51
+ class ConcurrencyLimitError(RateLimitError):
52
+ """429 `concurrency_limit` / `token_session_limit` — too many sessions open. End one first."""
53
+
54
+
55
+ class HostCapacityError(RateLimitError):
56
+ """429 `host_capacity` — the host has no free capacity right now. Retry shortly; sessions are never queued."""
57
+
58
+
59
+ class UpstreamError(LiveVideoError):
60
+ """5xx — the host (or the gateway) failed."""
61
+
62
+
63
+ class SessionStateError(LiveVideoError):
64
+ """Raised locally: an operation that needs a different session state (e.g. waiting on an ended session)."""
65
+
66
+
67
+ class TimeoutError(LiveVideoError): # noqa: A001 — deliberate: scoped to this package
68
+ """`wait_until_ready` ran out of time."""
69
+
70
+
71
+ def error_from_response(status: int, body: Any, headers: Mapping[str, str]) -> LiveVideoError:
72
+ err = (body.get("error") if isinstance(body, dict) else None) or {}
73
+ message = err.get("message") if isinstance(err, dict) else None
74
+ message = message or (body if isinstance(body, str) else f"request failed with HTTP {status}")
75
+ code = err.get("code") if isinstance(err, dict) else None
76
+ etype = err.get("type") if isinstance(err, dict) else None
77
+ retry_after = None
78
+ ra = headers.get("retry-after") or headers.get("Retry-After")
79
+ if ra:
80
+ try:
81
+ retry_after = float(ra)
82
+ except ValueError:
83
+ pass
84
+ kw = dict(status=status, code=code, type=etype, retry_after=retry_after, body=body)
85
+ if status == 401:
86
+ return AuthenticationError(message, **kw)
87
+ if status == 403:
88
+ return PermissionDeniedError(message, **kw)
89
+ if status == 404:
90
+ return NotFoundError(message, **kw)
91
+ if status == 402:
92
+ return InsufficientCreditsError(message, **kw)
93
+ if status == 429:
94
+ if code in ("concurrency_limit", "token_session_limit"):
95
+ return ConcurrencyLimitError(message, **kw)
96
+ if code == "host_capacity":
97
+ return HostCapacityError(message, **kw)
98
+ return RateLimitError(message, **kw)
99
+ if code == "pricing_unavailable":
100
+ return PricingUnavailableError(message, **kw)
101
+ if 400 <= status < 500:
102
+ return InvalidRequestError(message, **kw)
103
+ return UpstreamError(message, **kw)
104
+
105
+
106
+ class VideoGenerationError(LiveVideoError):
107
+ """Raised by `VideoJob.wait()` when a `/v1/videos` job ends in a failed state."""
@@ -0,0 +1,295 @@
1
+ """`LiveSession` (sync) and `AsyncLiveSession`: one live-video session. Mirrors the shape of Reactor's client
2
+ (`status`, `session_id`, `on/off/once`, `last_error`) on top of videorouter's broker model — the SDK never touches
3
+ media; `connect` is handed to the host's own SDK."""
4
+
5
+ from __future__ import annotations
6
+
7
+ import asyncio
8
+ import inspect
9
+ import threading
10
+ import time
11
+ from typing import Any, Callable, Optional
12
+
13
+ from ._errors import AuthenticationError, LiveVideoError, NotFoundError, SessionStateError, TimeoutError
14
+ from ._types import Connect, Status
15
+
16
+ EVENTS = ("status_changed", "ended", "heartbeat", "error")
17
+
18
+
19
+ class _Emitter:
20
+ def __init__(self) -> None:
21
+ self._handlers: dict[str, list[tuple[Callable, bool]]] = {}
22
+
23
+ def on(self, event: str, handler: Callable) -> None:
24
+ self._add(event, handler, False)
25
+
26
+ def once(self, event: str, handler: Callable) -> None:
27
+ self._add(event, handler, True)
28
+
29
+ def off(self, event: str, handler: Callable) -> None:
30
+ self._handlers[event] = [(h, o) for h, o in self._handlers.get(event, []) if h is not handler]
31
+
32
+ def _add(self, event: str, handler: Callable, once: bool) -> None:
33
+ if event not in EVENTS:
34
+ raise ValueError(f"unknown event {event!r} (events: {', '.join(EVENTS)})")
35
+ self._handlers.setdefault(event, []).append((handler, once))
36
+
37
+ def _emit(self, event: str, *args: Any) -> None:
38
+ for handler, once in list(self._handlers.get(event, [])):
39
+ if once:
40
+ self.off(event, handler)
41
+ try:
42
+ result = handler(*args)
43
+ if inspect.isawaitable(result):
44
+ try:
45
+ asyncio.get_running_loop().create_task(result) # type: ignore[arg-type]
46
+ except RuntimeError:
47
+ pass
48
+ except Exception: # noqa: BLE001 — a user handler must never break the SDK's own loop
49
+ pass
50
+
51
+
52
+ class _SessionBase(_Emitter):
53
+ """State + events shared by the sync and async session. `raw` is always the last server response."""
54
+
55
+ def __init__(self, client: Any, data: dict) -> None:
56
+ super().__init__()
57
+ self._client = client
58
+ self._connect: Optional[Connect] = None
59
+ self._last_error: Optional[LiveVideoError] = None
60
+ self._raw: dict = {}
61
+ self._status: Status = Status("waiting")
62
+ self._apply(data, emit=False)
63
+
64
+ # ── state (Reactor-style getters) ────────────────────────────────────────────────────────────────────────
65
+ @property
66
+ def id(self) -> str:
67
+ return self._raw["id"]
68
+
69
+ session_id = id
70
+
71
+ @property
72
+ def model(self) -> str:
73
+ return self._raw["model"]
74
+
75
+ @property
76
+ def status(self) -> Status:
77
+ return self._status
78
+
79
+ def get_status(self) -> Status:
80
+ return self._status
81
+
82
+ @property
83
+ def connect(self) -> Optional[Connect]:
84
+ """Host credentials. Returned by the server ONCE (on the response that minted them) and kept here for the life
85
+ of this object; `None` while `waiting` and for a session re-fetched with `get_session`."""
86
+ return self._connect
87
+
88
+ @property
89
+ def world_id(self) -> Optional[str]:
90
+ return self._raw.get("world_id")
91
+
92
+ @property
93
+ def billed_seconds(self) -> Optional[float]:
94
+ return self._raw.get("billed_seconds")
95
+
96
+ @property
97
+ def cost_usd(self) -> Optional[float]:
98
+ return self._raw.get("cost_usd")
99
+
100
+ @property
101
+ def end_reason(self) -> Optional[str]:
102
+ return self._raw.get("end_reason")
103
+
104
+ @property
105
+ def recording_url(self) -> Optional[str]:
106
+ return self._raw.get("recording_url")
107
+
108
+ @property
109
+ def expires_at(self) -> Optional[str]:
110
+ return self._raw.get("expires_at")
111
+
112
+ @property
113
+ def heartbeat_interval_seconds(self) -> float:
114
+ return float(self._raw.get("heartbeat_interval_seconds") or 5)
115
+
116
+ @property
117
+ def raw(self) -> dict:
118
+ return dict(self._raw)
119
+
120
+ @property
121
+ def last_error(self) -> Optional[LiveVideoError]:
122
+ return self._last_error
123
+
124
+ def get_last_error(self) -> Optional[LiveVideoError]:
125
+ return self._last_error
126
+
127
+ def __repr__(self) -> str:
128
+ return f"<{type(self).__name__} {self._raw.get('id')} {self.model} {self._status}>"
129
+
130
+ # ── internals ────────────────────────────────────────────────────────────────────────────────────────────
131
+ def _apply(self, data: dict, emit: bool = True) -> None:
132
+ previous = self._status
133
+ self._raw = {**self._raw, **{k: v for k, v in data.items() if k != "connect"}}
134
+ if data.get("connect"):
135
+ self._connect = data["connect"]
136
+ self._status = Status(self._raw.get("status", "waiting"))
137
+ if emit and self._status != previous:
138
+ self._emit("status_changed", self._status)
139
+ if self._status == Status.ENDED:
140
+ self._emit("ended", self)
141
+
142
+ def _fail(self, exc: LiveVideoError) -> None:
143
+ self._last_error = exc
144
+ self._emit("error", exc)
145
+
146
+ def _require_open(self, what: str) -> None:
147
+ if self._status == Status.ENDED:
148
+ raise SessionStateError(f"cannot {what}: session {self.id} has ended ({self.end_reason})")
149
+
150
+
151
+ class LiveSession(_SessionBase):
152
+ """Synchronous session. Heartbeats run on a daemon thread."""
153
+
154
+ def __init__(self, client: Any, data: dict) -> None:
155
+ super().__init__(client, data)
156
+ self._hb_thread: Optional[threading.Thread] = None
157
+ self._hb_stop = threading.Event()
158
+
159
+ def refresh(self) -> "LiveSession":
160
+ """Re-read the session. For a `waiting` session this also advances it: the call that sees the world finish
161
+ building mints the host credentials and returns them (then available as `.connect`)."""
162
+ self._apply(self._client._request("GET", f"/live-video/sessions/{self.id}", retry=True))
163
+ return self
164
+
165
+ def wait_until_ready(self, timeout: float = 300.0, poll_interval: float = 4.0) -> "LiveSession":
166
+ deadline = time.monotonic() + timeout
167
+ while self._status == Status.WAITING:
168
+ if time.monotonic() >= deadline:
169
+ raise TimeoutError(f"session {self.id} was still waiting after {timeout:g}s")
170
+ time.sleep(poll_interval)
171
+ self.refresh()
172
+ if self._status == Status.ENDED:
173
+ raise SessionStateError(f"session {self.id} ended while waiting ({self.end_reason})")
174
+ return self
175
+
176
+ def heartbeat(self) -> "LiveSession":
177
+ self._require_open("heartbeat")
178
+ self._apply(self._client._request("POST", f"/live-video/sessions/{self.id}/heartbeat", retry=True))
179
+ self._emit("heartbeat", self)
180
+ return self
181
+
182
+ def start_heartbeat(self, interval: Optional[float] = None) -> None:
183
+ """Beat in the background every `interval` seconds (default: the server's `heartbeat_interval_seconds`, 5 s)."""
184
+ if self._hb_thread and self._hb_thread.is_alive():
185
+ return
186
+ every = interval or self.heartbeat_interval_seconds
187
+ self._hb_stop.clear()
188
+
189
+ def loop() -> None:
190
+ while not self._hb_stop.wait(every):
191
+ if self._status == Status.ENDED:
192
+ return
193
+ try:
194
+ self.heartbeat()
195
+ except (NotFoundError, AuthenticationError, SessionStateError) as exc:
196
+ self._fail(exc)
197
+ return
198
+ except LiveVideoError as exc: # transient: keep beating, surface it
199
+ self._fail(exc)
200
+
201
+ self._hb_thread = threading.Thread(target=loop, name=f"live-video-heartbeat-{self.id}", daemon=True)
202
+ self._hb_thread.start()
203
+
204
+ def stop_heartbeat(self) -> None:
205
+ self._hb_stop.set()
206
+ t = self._hb_thread
207
+ if t and t.is_alive() and t is not threading.current_thread():
208
+ t.join(timeout=2)
209
+ self._hb_thread = None
210
+
211
+ def end(self, host_travel_id: Optional[str] = None) -> "LiveSession":
212
+ """Settle the session now. Safe to call twice. `host_travel_id` (Happy Oyster) lets the server bill on the host's
213
+ authoritative duration and fetch the replay."""
214
+ self.stop_heartbeat()
215
+ if self._status != Status.ENDED:
216
+ body = {"host_travel_id": host_travel_id} if host_travel_id else None
217
+ self._apply(self._client._request("POST", f"/live-video/sessions/{self.id}/end", json=body, retry=True))
218
+ return self
219
+
220
+ def __enter__(self) -> "LiveSession":
221
+ return self
222
+
223
+ def __exit__(self, *exc: Any) -> None:
224
+ self.end()
225
+
226
+
227
+ class AsyncLiveSession(_SessionBase):
228
+ """Asyncio session. Heartbeats run as a background task."""
229
+
230
+ def __init__(self, client: Any, data: dict) -> None:
231
+ super().__init__(client, data)
232
+ self._hb_task: Optional["asyncio.Task[None]"] = None
233
+
234
+ async def refresh(self) -> "AsyncLiveSession":
235
+ self._apply(await self._client._request("GET", f"/live-video/sessions/{self.id}", retry=True))
236
+ return self
237
+
238
+ async def wait_until_ready(self, timeout: float = 300.0, poll_interval: float = 4.0) -> "AsyncLiveSession":
239
+ deadline = time.monotonic() + timeout
240
+ while self._status == Status.WAITING:
241
+ if time.monotonic() >= deadline:
242
+ raise TimeoutError(f"session {self.id} was still waiting after {timeout:g}s")
243
+ await asyncio.sleep(poll_interval)
244
+ await self.refresh()
245
+ if self._status == Status.ENDED:
246
+ raise SessionStateError(f"session {self.id} ended while waiting ({self.end_reason})")
247
+ return self
248
+
249
+ async def heartbeat(self) -> "AsyncLiveSession":
250
+ self._require_open("heartbeat")
251
+ self._apply(await self._client._request("POST", f"/live-video/sessions/{self.id}/heartbeat", retry=True))
252
+ self._emit("heartbeat", self)
253
+ return self
254
+
255
+ def start_heartbeat(self, interval: Optional[float] = None) -> None:
256
+ if self._hb_task and not self._hb_task.done():
257
+ return
258
+ every = interval or self.heartbeat_interval_seconds
259
+
260
+ async def loop() -> None:
261
+ while self._status != Status.ENDED:
262
+ await asyncio.sleep(every)
263
+ if self._status == Status.ENDED:
264
+ return
265
+ try:
266
+ await self.heartbeat()
267
+ except (NotFoundError, AuthenticationError, SessionStateError) as exc:
268
+ self._fail(exc)
269
+ return
270
+ except LiveVideoError as exc:
271
+ self._fail(exc)
272
+
273
+ self._hb_task = asyncio.get_running_loop().create_task(loop())
274
+
275
+ async def stop_heartbeat(self) -> None:
276
+ task, self._hb_task = self._hb_task, None
277
+ if task and not task.done() and task is not asyncio.current_task():
278
+ task.cancel()
279
+ try:
280
+ await task
281
+ except (asyncio.CancelledError, Exception): # noqa: BLE001
282
+ pass
283
+
284
+ async def end(self, host_travel_id: Optional[str] = None) -> "AsyncLiveSession":
285
+ await self.stop_heartbeat()
286
+ if self._status != Status.ENDED:
287
+ body = {"host_travel_id": host_travel_id} if host_travel_id else None
288
+ self._apply(await self._client._request("POST", f"/live-video/sessions/{self.id}/end", json=body, retry=True))
289
+ return self
290
+
291
+ async def __aenter__(self) -> "AsyncLiveSession":
292
+ return self
293
+
294
+ async def __aexit__(self, *exc: Any) -> None:
295
+ await self.end()
@@ -0,0 +1,63 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+ from typing import Any, Optional
5
+
6
+
7
+ class Status(str):
8
+ """Session status. A `str` subclass, so `session.status == "ready"` works."""
9
+
10
+ WAITING = "waiting" # the host is building a world; nothing is metered yet
11
+ READY = "ready" # credentials issued; the meter is running
12
+ ENDED = "ended" # settled
13
+
14
+
15
+ Status.WAITING, Status.READY, Status.ENDED = Status("waiting"), Status("ready"), Status("ended")
16
+
17
+
18
+ @dataclass(frozen=True)
19
+ class Model:
20
+ id: str
21
+ kind: str # generate | world | avatar | edit
22
+ control: str # stream | actions
23
+ max_session_sec: int
24
+ available: bool # False until the model has a verified price
25
+ price_per_second_usd: Optional[float]
26
+ price_per_hour_usd: Optional[float]
27
+ resolutions: tuple = ()
28
+ description: str = ""
29
+ speeds: tuple = ()
30
+ fast_price_per_second_usd: Optional[float] = None
31
+ raw: dict = field(default_factory=dict, repr=False, compare=False)
32
+
33
+ @classmethod
34
+ def from_dict(cls, d: dict) -> "Model":
35
+ return cls(id=d["id"], kind=d.get("kind", ""), control=d.get("control", ""),
36
+ max_session_sec=int(d.get("max_session_sec") or 0), available=bool(d.get("available")),
37
+ price_per_second_usd=d.get("price_per_second_usd"), price_per_hour_usd=d.get("price_per_hour_usd"),
38
+ resolutions=tuple(d.get("resolutions") or ()), description=d.get("description", ""),
39
+ speeds=tuple(d.get("speeds") or ()), fast_price_per_second_usd=d.get("fast_price_per_second_usd"),
40
+ raw=d)
41
+
42
+
43
+ @dataclass(frozen=True)
44
+ class Token:
45
+ """A short-lived credential for a browser. Mint it on YOUR server and hand `token` to the client SDK."""
46
+ token: str
47
+ expires_at: str
48
+ models: Optional[list] = None
49
+ max_sessions: Optional[int] = None
50
+ max_session_duration_seconds: Optional[int] = None
51
+
52
+ @classmethod
53
+ def from_dict(cls, d: dict) -> "Token":
54
+ return cls(token=d["token"], expires_at=d["expires_at"], models=d.get("models"),
55
+ max_sessions=d.get("max_sessions"),
56
+ max_session_duration_seconds=d.get("max_session_duration_seconds"))
57
+
58
+
59
+ # `connect` is host-specific and handed to the HOST's own SDK; its shape per model family:
60
+ # Happy Oyster : {"ticket", "token", "api_base_url"}
61
+ # Odyssey : {"session_id", "signaling_url", "session_token", "expires_in", "capabilities"}
62
+ # Decart Lucy : {"api_key", "token", "base_url", "model", "expires_at", "max_session_duration_seconds"}
63
+ Connect = dict[str, Any]
@@ -0,0 +1,135 @@
1
+ """`client.videos` — the `/v1/videos` generation API (needs an API key; browser tokens are live-video only).
2
+
3
+ Params are the API's own fields, passed through unchanged: `model`, `prompt`, `duration_secs`, `resolution`,
4
+ `aspect_ratio`, `start_image_url`, ... See https://videorouter.sh/docs/video-generation.
5
+
6
+ job = lv.videos.generate({"model": "heygen-video-1", "prompt": "A presenter says: Welcome!", "duration_secs": 5})
7
+ print(job.url)
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import asyncio
13
+ import time
14
+ from typing import Any, Optional
15
+
16
+ from ._errors import LiveVideoError, TimeoutError, VideoGenerationError
17
+
18
+ _DONE = ("completed", "succeeded")
19
+ _FAILED = ("failed", "error", "cancelled")
20
+
21
+
22
+ class _JobBase:
23
+ def __init__(self, videos: Any, data: Optional[dict]) -> None:
24
+ self._videos = videos
25
+ self.raw: dict = dict(data or {})
26
+
27
+ @property
28
+ def id(self) -> Optional[str]:
29
+ return self.raw.get("id")
30
+
31
+ @property
32
+ def status(self) -> str:
33
+ """"queued" | "processing" | "completed" | "failed" (a host may also report `succeeded` / `cancelled`)."""
34
+ return self.raw.get("status") or "queued"
35
+
36
+ @property
37
+ def done(self) -> bool:
38
+ return self.status in _DONE
39
+
40
+ @property
41
+ def failed(self) -> bool:
42
+ return self.status in _FAILED
43
+
44
+ @property
45
+ def urls(self) -> list:
46
+ """Every output URL (usually one). Empty until the job completes."""
47
+ return [d["url"] for d in (self.raw.get("data") or []) if isinstance(d, dict) and d.get("url")]
48
+
49
+ @property
50
+ def url(self) -> Optional[str]:
51
+ urls = self.urls
52
+ return urls[0] if urls else None
53
+
54
+ @property
55
+ def error(self) -> Optional[dict]:
56
+ return self.raw.get("error")
57
+
58
+ def _failure(self) -> VideoGenerationError:
59
+ e = self.error or {}
60
+ return VideoGenerationError(e.get("message") or f"video {self.id} {self.status}", code=e.get("code"),
61
+ type=e.get("type"), body=self.raw)
62
+
63
+ def __repr__(self) -> str:
64
+ return f"<VideoJob {self.id} {self.status}>"
65
+
66
+
67
+ class VideoJob(_JobBase):
68
+ def refresh(self) -> "VideoJob":
69
+ self.raw = self._videos._get(self.id)
70
+ return self
71
+
72
+ def wait(self, timeout: float = 600.0, poll_interval: float = 5.0) -> "VideoJob":
73
+ """Poll until the job completes. Raises `VideoGenerationError` if it fails, `TimeoutError` if it runs out of time."""
74
+ deadline = time.monotonic() + timeout
75
+ while not self.done:
76
+ if self.failed:
77
+ raise self._failure()
78
+ if time.monotonic() >= deadline:
79
+ raise TimeoutError(f"video {self.id} was still {self.status} after {timeout}s")
80
+ time.sleep(poll_interval)
81
+ self.refresh()
82
+ return self
83
+
84
+
85
+ class AsyncVideoJob(_JobBase):
86
+ async def refresh(self) -> "AsyncVideoJob":
87
+ self.raw = await self._videos._get(self.id)
88
+ return self
89
+
90
+ async def wait(self, timeout: float = 600.0, poll_interval: float = 5.0) -> "AsyncVideoJob":
91
+ deadline = time.monotonic() + timeout
92
+ while not self.done:
93
+ if self.failed:
94
+ raise self._failure()
95
+ if time.monotonic() >= deadline:
96
+ raise TimeoutError(f"video {self.id} was still {self.status} after {timeout}s")
97
+ await asyncio.sleep(poll_interval)
98
+ await self.refresh()
99
+ return self
100
+
101
+
102
+ class Videos:
103
+ def __init__(self, client: Any) -> None:
104
+ self._client = client
105
+
106
+ def create(self, params: dict) -> VideoJob:
107
+ """Submit a job. Never retried: a retried create could be billed twice."""
108
+ return VideoJob(self, self._client._request("POST", "/videos", json=params))
109
+
110
+ def get(self, video_id: str) -> VideoJob:
111
+ return VideoJob(self, self._get(video_id))
112
+
113
+ def generate(self, params: dict, *, timeout: float = 600.0, poll_interval: float = 5.0) -> VideoJob:
114
+ """Create and wait; returns the finished job (`job.url`)."""
115
+ return self.create(params).wait(timeout=timeout, poll_interval=poll_interval)
116
+
117
+ def _get(self, video_id: str) -> dict:
118
+ return self._client._request("GET", f"/videos/{video_id}", retry=True)
119
+
120
+
121
+ class AsyncVideos:
122
+ def __init__(self, client: Any) -> None:
123
+ self._client = client
124
+
125
+ async def create(self, params: dict) -> AsyncVideoJob:
126
+ return AsyncVideoJob(self, await self._client._request("POST", "/videos", json=params))
127
+
128
+ async def get(self, video_id: str) -> AsyncVideoJob:
129
+ return AsyncVideoJob(self, await self._get(video_id))
130
+
131
+ async def generate(self, params: dict, *, timeout: float = 600.0, poll_interval: float = 5.0) -> AsyncVideoJob:
132
+ return await (await self.create(params)).wait(timeout=timeout, poll_interval=poll_interval)
133
+
134
+ async def _get(self, video_id: str) -> dict:
135
+ return await self._client._request("GET", f"/videos/{video_id}", retry=True)
File without changes
@@ -0,0 +1,111 @@
1
+ Metadata-Version: 2.4
2
+ Name: videorouter-live
3
+ Version: 0.2.0
4
+ Summary: Python SDK for videorouter.sh: live video & world models (open, keep alive, settle a real-time session) and the /v1/videos generation API.
5
+ License: MIT
6
+ Project-URL: Homepage, https://videorouter.sh/docs/live-video
7
+ Keywords: videorouter,video,world-model,real-time,live-video,sdk
8
+ Classifier: License :: OSI Approved :: MIT License
9
+ Classifier: Programming Language :: Python :: 3
10
+ Classifier: Typing :: Typed
11
+ Requires-Python: >=3.9
12
+ Description-Content-Type: text/markdown
13
+ License-File: LICENSE
14
+ Requires-Dist: httpx>=0.25
15
+ Dynamic: license-file
16
+
17
+ # videorouter-live (Python)
18
+
19
+ Python SDK for [videorouter.sh live video & world models](https://videorouter.sh/docs/live-video): open a real-time
20
+ session, keep it alive, settle it. Sync (`LiveVideo`) and asyncio (`AsyncLiveVideo`) clients; only dependency is `httpx`.
21
+
22
+ The SDK talks to the session **broker**. It never carries media: `session.connect` holds the host's short-lived
23
+ credentials, which you hand to the host's own SDK (Happy Oyster, Odyssey, Decart...) in the browser.
24
+
25
+ ```python
26
+ from videorouter_live import LiveVideo
27
+
28
+ lv = LiveVideo(api_key="llmr_sk_...") # or set VIDEOROUTER_API_KEY
29
+
30
+ with lv.session("fal/happy-oyster-adventure", max_duration_seconds=60,
31
+ options={"world": {"prompt": "a rainy cyberpunk city", "perspective": "first_person"}}) as s:
32
+ send_to_browser(s.connect) # host credentials — returned once by the server
33
+ ... # heartbeats run in the background; the session is settled on exit
34
+ print(s.billed_seconds, s.cost_usd, s.end_reason)
35
+ ```
36
+
37
+ `lv.session(...)` creates the session, waits until it is `ready` (a world may need ~30–120 s to build; nothing is
38
+ metered while `waiting`), starts the heartbeat and settles it on exit even if your code raised.
39
+
40
+ ## Browser tokens
41
+
42
+ Never put your key in a browser. Mint a short-lived, live-video-only token on your server:
43
+
44
+ ```python
45
+ token = lv.create_token(models=["fal/happy-oyster-adventure"], expires_in=300,
46
+ max_sessions=1, max_session_duration_seconds=60)
47
+ # hand token.token to the browser SDK: new LiveVideo({ token })
48
+ ```
49
+
50
+ A token can only call `/v1/live-video` session endpoints — not billable endpoints, and not `create_token`.
51
+
52
+ ## Generating videos
53
+
54
+ Both clients also wrap `/v1/videos` (API key required; browser tokens are live-video only). Creating a job is never retried.
55
+
56
+ ```python
57
+ lv = LiveVideo() # reads VIDEOROUTER_API_KEY
58
+ job = lv.videos.generate({"model": "heygen-video-1", "prompt": "A presenter says: Welcome!", "duration_secs": 5})
59
+ print(job.url)
60
+
61
+ queued = lv.videos.create({"model": "wan-3.0-prime", "prompt": "..."})
62
+ queued.wait(timeout=600) # raises VideoGenerationError / TimeoutError
63
+ ```
64
+
65
+ `job.status`, `.done`, `.failed`, `.url` / `.urls`, `.error`, `.raw`, `.refresh()`, `.wait(timeout, poll_interval)`;
66
+ `AsyncLiveVideo` has the same surface as coroutines.
67
+
68
+ ## API
69
+
70
+ | | |
71
+ |---|---|
72
+ | `LiveVideo(api_key=None, *, token=None, base_url=..., timeout=30, max_retries=2, http_client=None)` | `AsyncLiveVideo` has the same signature |
73
+ | `lv.models()` | `list[Model]` — `available` is `False` until a model has a verified price |
74
+ | `lv.create_token(*, models, expires_in, max_sessions, max_session_duration_seconds)` | `Token` |
75
+ | `lv.create_session(model, *, max_duration_seconds, options)` | `LiveSession` |
76
+ | `lv.get_session(id)` | `LiveSession` (never has `connect` — it is returned once) |
77
+ | `lv.session(model, ..., wait_until_ready=True, auto_heartbeat=True)` | context manager |
78
+
79
+ `LiveSession` (and `AsyncLiveSession`, where the network methods are coroutines), modelled on Reactor's client:
80
+
81
+ | | |
82
+ |---|---|
83
+ | `.id` / `.session_id`, `.model`, `.status`, `.get_status()` | `Status` is a `str` subclass: `"waiting"`, `"ready"`, `"ended"` |
84
+ | `.connect` | host credentials, or `None` |
85
+ | `.billed_seconds`, `.cost_usd`, `.end_reason`, `.recording_url`, `.world_id`, `.raw` | |
86
+ | `.refresh()` | re-read; advances a `waiting` session and returns `connect` when the world is ready |
87
+ | `.wait_until_ready(timeout=300, poll_interval=4)` | raises `TimeoutError` / `SessionStateError` |
88
+ | `.heartbeat()`, `.start_heartbeat(interval=None)`, `.stop_heartbeat()` | default interval is the server's 5 s |
89
+ | `.end(host_travel_id=None)` | settle; safe to call twice |
90
+ | `.on(event, fn)`, `.once(...)`, `.off(...)` | events: `status_changed`, `ended`, `heartbeat`, `error` |
91
+ | `.last_error` / `.get_last_error()` | last background-heartbeat error |
92
+
93
+ ### Errors
94
+
95
+ Every failure raises a `LiveVideoError` subclass carrying `status`, `code`, `type`, `retry_after`:
96
+ `AuthenticationError` (401), `PermissionDeniedError` (403), `InvalidRequestError` (400),
97
+ `InsufficientCreditsError` (402), `PricingUnavailableError` (502), `NotFoundError` (404),
98
+ `RateLimitError` (429) → `ConcurrencyLimitError` / `HostCapacityError`, `UpstreamError` (5xx), plus
99
+ `SessionStateError` and `TimeoutError` raised locally.
100
+
101
+ ### Retries
102
+
103
+ GETs, heartbeats and `end` are retried on network errors and 502/503/504. **Creating a session or a token is never
104
+ retried** — a retried create could open a second billed session.
105
+
106
+ ## Billing in one paragraph
107
+
108
+ Opening a session holds `max_duration_seconds × price` against your credits; ending it debits the real cost
109
+ (whole seconds, at least the host's own reported duration) and releases the rest. A session that never became ready
110
+ costs nothing. If the heartbeat stops, the server settles the session itself — a forgotten tab costs at most the
111
+ silence window.
@@ -0,0 +1,12 @@
1
+ videorouter_live/__init__.py,sha256=vofWlyLlkxFXLf2zj8KHHTl6WEh9ODKLzIKzuwlfcjs,1498
2
+ videorouter_live/_client.py,sha256=3iouDC6xtSySq7UQYd5bn_syHbkDBYUfwmN6kXeM0Bs,12089
3
+ videorouter_live/_errors.py,sha256=k_p2zxGX_ExKBehVOT22ThnulYgtfvhaDzkJN117ZGQ,4160
4
+ videorouter_live/_session.py,sha256=RYmGfKRWGpSNe6onSDardOuwsfaqBKfRjckChnSl7J8,11867
5
+ videorouter_live/_types.py,sha256=WMPGQDU7f5B2FDrImViIjXl0vLYoc8t5f3lUfAXS8tE,2666
6
+ videorouter_live/_videos.py,sha256=s84zMMtssrx6IJ3qwnI1rkjGCXvcDGBNDcR2vwkWgrs,4947
7
+ videorouter_live/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
8
+ videorouter_live-0.2.0.dist-info/licenses/LICENSE,sha256=77GQ3aAhJiYTeh2sa0vlJANI_9CPVUigSvKG6eyngY8,1068
9
+ videorouter_live-0.2.0.dist-info/METADATA,sha256=FiqUg6k_9AlUWTNXeDrBd_YBVANGGy2idoHTfo__2os,5627
10
+ videorouter_live-0.2.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
11
+ videorouter_live-0.2.0.dist-info/top_level.txt,sha256=4RlIubOLiZNqiZ0Xb67SLpVZYReFHi-K-5t-LWiEOeI,17
12
+ videorouter_live-0.2.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Synvia Corp
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ videorouter_live