videopiper 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.
- videopiper/__init__.py +16 -0
- videopiper/_http.py +207 -0
- videopiper/_version.py +1 -0
- videopiper/client.py +72 -0
- videopiper/errors.py +51 -0
- videopiper/py.typed +0 -0
- videopiper/resources.py +457 -0
- videopiper/types.py +165 -0
- videopiper/webhooks.py +55 -0
- videopiper-0.1.0.dist-info/METADATA +226 -0
- videopiper-0.1.0.dist-info/RECORD +14 -0
- videopiper-0.1.0.dist-info/WHEEL +5 -0
- videopiper-0.1.0.dist-info/licenses/LICENSE +21 -0
- videopiper-0.1.0.dist-info/top_level.txt +1 -0
videopiper/__init__.py
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""Official Python SDK for the Video Piper API (https://videopiper.com/docs/)."""
|
|
2
|
+
from ._version import __version__
|
|
3
|
+
from .client import DEFAULT_BASE_URL, VideoPiper
|
|
4
|
+
from .errors import VideoPiperConnectionError, VideoPiperError, WaitTimeoutError, WebhookVerificationError
|
|
5
|
+
from .webhooks import verify_webhook
|
|
6
|
+
|
|
7
|
+
__all__ = [
|
|
8
|
+
"DEFAULT_BASE_URL",
|
|
9
|
+
"VideoPiper",
|
|
10
|
+
"VideoPiperConnectionError",
|
|
11
|
+
"VideoPiperError",
|
|
12
|
+
"WaitTimeoutError",
|
|
13
|
+
"WebhookVerificationError",
|
|
14
|
+
"__version__",
|
|
15
|
+
"verify_webhook",
|
|
16
|
+
]
|
videopiper/_http.py
ADDED
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
import email.utils
|
|
2
|
+
import http.client
|
|
3
|
+
import json
|
|
4
|
+
import random
|
|
5
|
+
import threading
|
|
6
|
+
import time
|
|
7
|
+
import urllib.error
|
|
8
|
+
import urllib.parse
|
|
9
|
+
import urllib.request
|
|
10
|
+
from collections import deque
|
|
11
|
+
from typing import Any, Dict, Mapping, NamedTuple, Optional
|
|
12
|
+
|
|
13
|
+
from ._version import __version__
|
|
14
|
+
from .errors import VideoPiperConnectionError, VideoPiperError
|
|
15
|
+
|
|
16
|
+
USER_AGENT = f"videopiper-python/{__version__}"
|
|
17
|
+
|
|
18
|
+
# A 429 is only worth retrying when the limit is the per-second one; the daily and test limits don't clear in seconds.
|
|
19
|
+
_RETRYABLE_429 = {"RATE_LIMITED", "HTTP_429"}
|
|
20
|
+
_RETRYABLE_STATUS = {500, 502, 503, 504}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class Response(NamedTuple):
|
|
24
|
+
status: int
|
|
25
|
+
headers: Mapping[str, str] # names in lower case
|
|
26
|
+
body: bytes
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class TransportError(Exception):
|
|
30
|
+
"""The request got no answer: a network failure or a timeout."""
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class UrllibTransport:
|
|
34
|
+
"""Sends requests with the standard library. Any object with the same ``send`` can replace it (a test double, a proxy)."""
|
|
35
|
+
|
|
36
|
+
def send(self, method: str, url: str, headers: Mapping[str, str], body: Optional[bytes], timeout: float) -> Response:
|
|
37
|
+
request = urllib.request.Request(url, data=body, method=method, headers=dict(headers))
|
|
38
|
+
try:
|
|
39
|
+
with urllib.request.urlopen(request, timeout=timeout) as response:
|
|
40
|
+
return Response(response.status, _lower(response.headers), response.read())
|
|
41
|
+
except urllib.error.HTTPError as error:
|
|
42
|
+
with error:
|
|
43
|
+
return Response(error.code, _lower(error.headers), error.read())
|
|
44
|
+
except (urllib.error.URLError, http.client.HTTPException, OSError) as error:
|
|
45
|
+
raise TransportError(str(getattr(error, "reason", error))) from error
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _lower(headers: Any) -> Dict[str, str]:
|
|
49
|
+
return {name.lower(): value for name, value in headers.items()}
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class _RateLimiter:
|
|
53
|
+
"""At most ``per_second`` calls in any one second (the API allows 5 per key). 0 turns it off."""
|
|
54
|
+
|
|
55
|
+
def __init__(self, per_second: int) -> None:
|
|
56
|
+
self._per_second = per_second
|
|
57
|
+
self._stamps: "deque[float]" = deque()
|
|
58
|
+
self._lock = threading.Lock()
|
|
59
|
+
|
|
60
|
+
def wait(self) -> None:
|
|
61
|
+
if self._per_second <= 0:
|
|
62
|
+
return
|
|
63
|
+
while True:
|
|
64
|
+
with self._lock:
|
|
65
|
+
now = time.monotonic()
|
|
66
|
+
while self._stamps and self._stamps[0] <= now - 1.0:
|
|
67
|
+
self._stamps.popleft()
|
|
68
|
+
if len(self._stamps) < self._per_second:
|
|
69
|
+
self._stamps.append(now)
|
|
70
|
+
return
|
|
71
|
+
delay = self._stamps[0] + 1.0 - now
|
|
72
|
+
time.sleep(delay)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _retry_after(value: Optional[str]) -> Optional[float]:
|
|
76
|
+
"""Retry-After in seconds (it is seconds or an HTTP date), or None."""
|
|
77
|
+
if not value:
|
|
78
|
+
return None
|
|
79
|
+
try:
|
|
80
|
+
return max(0.0, float(value))
|
|
81
|
+
except ValueError:
|
|
82
|
+
pass
|
|
83
|
+
try:
|
|
84
|
+
when = email.utils.parsedate_to_datetime(value)
|
|
85
|
+
except (TypeError, ValueError):
|
|
86
|
+
return None
|
|
87
|
+
return max(0.0, when.timestamp() - time.time())
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _to_error(response: Response) -> VideoPiperError:
|
|
91
|
+
body: Dict[str, Any] = {}
|
|
92
|
+
text = response.body.decode("utf-8", "replace")
|
|
93
|
+
try:
|
|
94
|
+
parsed = json.loads(text)
|
|
95
|
+
if isinstance(parsed, dict):
|
|
96
|
+
body = parsed
|
|
97
|
+
except ValueError:
|
|
98
|
+
pass # Not JSON (a gateway page, or storage's XML): fall back to the status and the start of the text.
|
|
99
|
+
details = body.get("details")
|
|
100
|
+
return VideoPiperError(
|
|
101
|
+
body["message"] if isinstance(body.get("message"), str) else (text[:300] or f"HTTP {response.status}"),
|
|
102
|
+
status=response.status,
|
|
103
|
+
code=body["error"] if isinstance(body.get("error"), str) else f"HTTP_{response.status}",
|
|
104
|
+
request_id=body.get("requestId") if isinstance(body.get("requestId"), str) else response.headers.get("x-amzn-requestid"),
|
|
105
|
+
details=details if isinstance(details, list) else None,
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
class HttpClient:
|
|
110
|
+
def __init__(
|
|
111
|
+
self,
|
|
112
|
+
*,
|
|
113
|
+
api_key: str,
|
|
114
|
+
base_url: str,
|
|
115
|
+
timeout: float,
|
|
116
|
+
upload_timeout: float,
|
|
117
|
+
max_retries: int,
|
|
118
|
+
retry_base_delay: float,
|
|
119
|
+
max_retry_delay: float,
|
|
120
|
+
requests_per_second: int,
|
|
121
|
+
transport: Any,
|
|
122
|
+
) -> None:
|
|
123
|
+
self._api_key = api_key
|
|
124
|
+
self._base_url = base_url
|
|
125
|
+
self._timeout = timeout
|
|
126
|
+
self._upload_timeout = upload_timeout
|
|
127
|
+
self._max_retries = max_retries
|
|
128
|
+
self._retry_base_delay = retry_base_delay
|
|
129
|
+
self._max_retry_delay = max_retry_delay
|
|
130
|
+
self._transport = transport
|
|
131
|
+
self._limiter = _RateLimiter(requests_per_second)
|
|
132
|
+
|
|
133
|
+
def request(
|
|
134
|
+
self,
|
|
135
|
+
method: str,
|
|
136
|
+
path: str,
|
|
137
|
+
*,
|
|
138
|
+
query: Optional[Mapping[str, Any]] = None,
|
|
139
|
+
body: Any = None,
|
|
140
|
+
idempotency_key: Optional[str] = None,
|
|
141
|
+
retry: bool = False,
|
|
142
|
+
) -> Any:
|
|
143
|
+
"""A call to the API: authenticated, rate limited, JSON in and out.
|
|
144
|
+
|
|
145
|
+
``retry`` says a POST is safe to send again; GET, PUT, PATCH and DELETE always are, and so is any request
|
|
146
|
+
that carries an idempotency key.
|
|
147
|
+
"""
|
|
148
|
+
url = self._base_url + path
|
|
149
|
+
pairs = [(name, _query_value(value)) for name, value in (query or {}).items() if value is not None]
|
|
150
|
+
if pairs:
|
|
151
|
+
url += "?" + urllib.parse.urlencode(pairs)
|
|
152
|
+
headers = {"Authorization": f"Bearer {self._api_key}", "Accept": "application/json", "User-Agent": USER_AGENT}
|
|
153
|
+
data = None
|
|
154
|
+
if body is not None:
|
|
155
|
+
headers["Content-Type"] = "application/json"
|
|
156
|
+
data = json.dumps(body).encode("utf-8")
|
|
157
|
+
if idempotency_key:
|
|
158
|
+
headers["Idempotency-Key"] = idempotency_key
|
|
159
|
+
retryable = method != "POST" or retry or bool(idempotency_key)
|
|
160
|
+
response = self._send(method, url, headers, data, self._timeout, retryable, limited=True)
|
|
161
|
+
return json.loads(response.body) if response.body else None
|
|
162
|
+
|
|
163
|
+
def put_part(self, url: str, data: bytes) -> None:
|
|
164
|
+
"""Sends one part's bytes to its pre-signed URL: no Authorization header, no rate limit (it is storage, not the API)."""
|
|
165
|
+
headers = {"User-Agent": USER_AGENT, "Content-Type": "application/octet-stream"}
|
|
166
|
+
self._send("PUT", url, headers, data, self._upload_timeout, True, limited=False)
|
|
167
|
+
|
|
168
|
+
def _send(
|
|
169
|
+
self,
|
|
170
|
+
method: str,
|
|
171
|
+
url: str,
|
|
172
|
+
headers: Mapping[str, str],
|
|
173
|
+
data: Optional[bytes],
|
|
174
|
+
timeout: float,
|
|
175
|
+
retryable: bool,
|
|
176
|
+
*,
|
|
177
|
+
limited: bool,
|
|
178
|
+
) -> Response:
|
|
179
|
+
attempt = 0
|
|
180
|
+
while True:
|
|
181
|
+
if limited:
|
|
182
|
+
self._limiter.wait()
|
|
183
|
+
can_retry = retryable and attempt < self._max_retries
|
|
184
|
+
retry_after: Optional[float] = None
|
|
185
|
+
try:
|
|
186
|
+
response = self._transport.send(method, url, headers, data, timeout)
|
|
187
|
+
except TransportError as error:
|
|
188
|
+
failure: VideoPiperError = VideoPiperConnectionError(str(error))
|
|
189
|
+
if not can_retry:
|
|
190
|
+
raise failure from error
|
|
191
|
+
else:
|
|
192
|
+
if 200 <= response.status < 300:
|
|
193
|
+
return response
|
|
194
|
+
failure = _to_error(response)
|
|
195
|
+
retry_after = _retry_after(response.headers.get("retry-after"))
|
|
196
|
+
worth_retrying = failure.code in _RETRYABLE_429 if response.status == 429 else response.status in _RETRYABLE_STATUS
|
|
197
|
+
if not (can_retry and worth_retrying):
|
|
198
|
+
raise failure
|
|
199
|
+
delay = retry_after if retry_after is not None else random.uniform(0, min(8.0, self._retry_base_delay * 2 ** attempt))
|
|
200
|
+
if delay > self._max_retry_delay:
|
|
201
|
+
raise failure
|
|
202
|
+
time.sleep(delay)
|
|
203
|
+
attempt += 1
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _query_value(value: Any) -> str:
|
|
207
|
+
return str(value).lower() if isinstance(value, bool) else str(value)
|
videopiper/_version.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
videopiper/client.py
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from typing import Any, Optional
|
|
3
|
+
|
|
4
|
+
from ._http import HttpClient, UrllibTransport
|
|
5
|
+
from .resources import (
|
|
6
|
+
AccountApi,
|
|
7
|
+
Edits,
|
|
8
|
+
EditTokens,
|
|
9
|
+
LiveStreams,
|
|
10
|
+
ThumbnailTokens,
|
|
11
|
+
UploadTokens,
|
|
12
|
+
UsageApi,
|
|
13
|
+
Videos,
|
|
14
|
+
Webhooks,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
DEFAULT_BASE_URL = "https://api.videopiper.dev/v1"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class VideoPiper:
|
|
21
|
+
"""The Video Piper API client.
|
|
22
|
+
|
|
23
|
+
:param api_key: your API key (``vp_live_...``, or ``vp_test_...`` for test mode). Default: the
|
|
24
|
+
``VIDEO_PIPER_API_KEY`` environment variable.
|
|
25
|
+
:param base_url: default: the ``VIDEO_PIPER_API_URL`` environment variable, else https://api.videopiper.dev/v1.
|
|
26
|
+
:param timeout: seconds a request may wait for the network (default 60).
|
|
27
|
+
:param upload_timeout: the same for a part upload (default 600).
|
|
28
|
+
:param max_retries: times a failed request that is safe to repeat is sent again (default 3). 0 turns retries off.
|
|
29
|
+
:param retry_base_delay: the first retry waits up to this many seconds, the next up to twice as long, and so on,
|
|
30
|
+
with jitter (default 0.5).
|
|
31
|
+
:param max_retry_delay: a ``Retry-After`` longer than this is not waited for; the error is raised instead (default 60).
|
|
32
|
+
:param requests_per_second: the API allows 5 per second per key, and the client keeps to it by default. 0 turns
|
|
33
|
+
the limit off.
|
|
34
|
+
:param transport: an object with ``send(method, url, headers, body, timeout)`` to use instead of ``urllib``.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
def __init__(
|
|
38
|
+
self,
|
|
39
|
+
api_key: Optional[str] = None,
|
|
40
|
+
*,
|
|
41
|
+
base_url: Optional[str] = None,
|
|
42
|
+
timeout: float = 60.0,
|
|
43
|
+
upload_timeout: float = 600.0,
|
|
44
|
+
max_retries: int = 3,
|
|
45
|
+
retry_base_delay: float = 0.5,
|
|
46
|
+
max_retry_delay: float = 60.0,
|
|
47
|
+
requests_per_second: int = 5,
|
|
48
|
+
transport: Any = None,
|
|
49
|
+
) -> None:
|
|
50
|
+
key = api_key or os.environ.get("VIDEO_PIPER_API_KEY")
|
|
51
|
+
if not key:
|
|
52
|
+
raise ValueError("No API key: pass api_key or set VIDEO_PIPER_API_KEY")
|
|
53
|
+
http = HttpClient(
|
|
54
|
+
api_key=key,
|
|
55
|
+
base_url=(base_url or os.environ.get("VIDEO_PIPER_API_URL") or DEFAULT_BASE_URL).rstrip("/"),
|
|
56
|
+
timeout=timeout,
|
|
57
|
+
upload_timeout=upload_timeout,
|
|
58
|
+
max_retries=max_retries,
|
|
59
|
+
retry_base_delay=retry_base_delay,
|
|
60
|
+
max_retry_delay=max_retry_delay,
|
|
61
|
+
requests_per_second=requests_per_second,
|
|
62
|
+
transport=transport or UrllibTransport(),
|
|
63
|
+
)
|
|
64
|
+
self.videos = Videos(http)
|
|
65
|
+
self.edits = Edits(http)
|
|
66
|
+
self.upload_tokens = UploadTokens(http)
|
|
67
|
+
self.edit_tokens = EditTokens(http)
|
|
68
|
+
self.thumbnail_tokens = ThumbnailTokens(http)
|
|
69
|
+
self.usage = UsageApi(http)
|
|
70
|
+
self.account = AccountApi(http)
|
|
71
|
+
self.webhooks = Webhooks(http)
|
|
72
|
+
self.live_streams = LiveStreams(http)
|
videopiper/errors.py
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
from typing import Any, Dict, List, Optional
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class VideoPiperError(Exception):
|
|
5
|
+
"""Any failed call.
|
|
6
|
+
|
|
7
|
+
For an error the API answered, ``status`` is the HTTP status and ``code`` its stable ``error`` code
|
|
8
|
+
(PLAN_LIMIT_REACHED, RATE_LIMITED, ...): program against ``code``, show ``message``. Quote ``request_id`` when
|
|
9
|
+
asking for help. ``details`` lists every problem of a rejected request (400 INVALID_REQUEST) as
|
|
10
|
+
``{"field": ..., "message": ...}``.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
def __init__(
|
|
14
|
+
self,
|
|
15
|
+
message: str,
|
|
16
|
+
*,
|
|
17
|
+
status: int = 0,
|
|
18
|
+
code: str = "",
|
|
19
|
+
request_id: Optional[str] = None,
|
|
20
|
+
details: Optional[List[Dict[str, Any]]] = None,
|
|
21
|
+
) -> None:
|
|
22
|
+
super().__init__(message)
|
|
23
|
+
self.message = message
|
|
24
|
+
self.status = status
|
|
25
|
+
self.code = code
|
|
26
|
+
self.request_id = request_id
|
|
27
|
+
self.details = details
|
|
28
|
+
|
|
29
|
+
def __str__(self) -> str:
|
|
30
|
+
return f"{self.status} {self.code}: {self.message}" if self.status else f"{self.code}: {self.message}"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class VideoPiperConnectionError(VideoPiperError):
|
|
34
|
+
"""The request never got an answer (network failure or timeout). ``status`` is 0, ``code`` CONNECTION_ERROR."""
|
|
35
|
+
|
|
36
|
+
def __init__(self, message: str) -> None:
|
|
37
|
+
super().__init__(message, code="CONNECTION_ERROR")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class WaitTimeoutError(VideoPiperError):
|
|
41
|
+
"""``wait_until_done`` ran out of time. ``status`` is 0, ``code`` WAIT_TIMEOUT; ``last_status`` is the last one seen."""
|
|
42
|
+
|
|
43
|
+
def __init__(self, video_id: str, timeout: float, last_status: Optional[str]) -> None:
|
|
44
|
+
super().__init__(
|
|
45
|
+
f"Video {video_id} was still {last_status or 'unknown'} after {round(timeout)}s", code="WAIT_TIMEOUT"
|
|
46
|
+
)
|
|
47
|
+
self.last_status = last_status
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class WebhookVerificationError(ValueError):
|
|
51
|
+
"""A webhook delivery that can't be trusted: a missing header, a bad or old signature, or a body that isn't JSON."""
|
videopiper/py.typed
ADDED
|
File without changes
|
videopiper/resources.py
ADDED
|
@@ -0,0 +1,457 @@
|
|
|
1
|
+
"""The API's areas as objects on the client: ``client.videos``, ``client.edits``, ``client.webhooks`` and so on.
|
|
2
|
+
|
|
3
|
+
Arguments are Python names (``file_name``, ``max_height``); they are sent with the API's camelCase names. Objects inside
|
|
4
|
+
a request (``trim``, ``redactions``, ``clips``, ``metadata``) are sent exactly as the API documents them. Methods that
|
|
5
|
+
start something take ``dry_run=True`` (check the request without creating or starting anything) and, where the API
|
|
6
|
+
supports it, ``idempotency_key`` (the same key and request answers the first response again, so a retry can't create a
|
|
7
|
+
second video).
|
|
8
|
+
"""
|
|
9
|
+
import base64
|
|
10
|
+
import io
|
|
11
|
+
import os
|
|
12
|
+
import re
|
|
13
|
+
import time
|
|
14
|
+
from typing import IO, Any, BinaryIO, Callable, Dict, Iterator, List, Optional, Union
|
|
15
|
+
|
|
16
|
+
from ._http import HttpClient
|
|
17
|
+
from .errors import WaitTimeoutError
|
|
18
|
+
from .types import (
|
|
19
|
+
CompleteResponse,
|
|
20
|
+
CreateUploadResponse,
|
|
21
|
+
EditResponse,
|
|
22
|
+
ImportResponse,
|
|
23
|
+
LiveIngest,
|
|
24
|
+
LiveStream,
|
|
25
|
+
LiveStreamsPage,
|
|
26
|
+
PartUrlsResponse,
|
|
27
|
+
ReprocessResponse,
|
|
28
|
+
Usage,
|
|
29
|
+
VideoStatus,
|
|
30
|
+
VideosPage,
|
|
31
|
+
VideoSummary,
|
|
32
|
+
WebhookConfig,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
# Statuses after which a video won't change.
|
|
36
|
+
FINAL_STATUSES = frozenset({"SUCCEEDED", "FAILED", "TIMED_OUT", "ABORTED", "HISTORY_UNAVAILABLE"})
|
|
37
|
+
|
|
38
|
+
Progress = Callable[[Dict[str, Any]], None]
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _camel(name: str) -> str:
|
|
42
|
+
return re.sub(r"_([a-z])", lambda match: match.group(1).upper(), name)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _body(options: Dict[str, Any], keep_none: bool = False) -> Dict[str, Any]:
|
|
46
|
+
return {_camel(name): value for name, value in options.items() if keep_none or value is not None}
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _write(
|
|
50
|
+
http: HttpClient,
|
|
51
|
+
path: str,
|
|
52
|
+
body: Dict[str, Any],
|
|
53
|
+
*,
|
|
54
|
+
dry_run: bool = False,
|
|
55
|
+
idempotency_key: Optional[str] = None,
|
|
56
|
+
safe_to_repeat: bool = False,
|
|
57
|
+
) -> Any:
|
|
58
|
+
return http.request(
|
|
59
|
+
"POST",
|
|
60
|
+
path,
|
|
61
|
+
body=body,
|
|
62
|
+
query={"dryRun": True} if dry_run else None,
|
|
63
|
+
# Dry runs change nothing, so they can always be sent again; the API ignores keys on them.
|
|
64
|
+
idempotency_key=None if dry_run else idempotency_key,
|
|
65
|
+
retry=dry_run or safe_to_repeat,
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _read_exactly(stream: IO[bytes], size: int) -> bytes:
|
|
70
|
+
chunks = []
|
|
71
|
+
while size > 0:
|
|
72
|
+
chunk = stream.read(size)
|
|
73
|
+
if not chunk:
|
|
74
|
+
raise ValueError("The stream ended before the size given for the upload")
|
|
75
|
+
chunks.append(chunk)
|
|
76
|
+
size -= len(chunk)
|
|
77
|
+
return b"".join(chunks)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class Videos:
|
|
81
|
+
def __init__(self, http: HttpClient) -> None:
|
|
82
|
+
self._http = http
|
|
83
|
+
|
|
84
|
+
def create(
|
|
85
|
+
self,
|
|
86
|
+
file_name: str,
|
|
87
|
+
size_bytes: Optional[int] = None,
|
|
88
|
+
*,
|
|
89
|
+
idempotency_key: Optional[str] = None,
|
|
90
|
+
dry_run: bool = False,
|
|
91
|
+
**options: Any,
|
|
92
|
+
) -> CreateUploadResponse:
|
|
93
|
+
"""Starts an upload. Send the bytes with ``get_part_urls`` and ``complete``, or use ``upload_file``.
|
|
94
|
+
|
|
95
|
+
Options: ``title``, ``description``, ``max_height``, ``metadata``, ``camera``, ``layout``, ``streaming``,
|
|
96
|
+
``prestart``, ``duration_seconds``.
|
|
97
|
+
"""
|
|
98
|
+
body = _body({"file_name": file_name, "size_bytes": size_bytes, **options})
|
|
99
|
+
return _write(self._http, "/videos", body, dry_run=dry_run, idempotency_key=idempotency_key)
|
|
100
|
+
|
|
101
|
+
def import_url(
|
|
102
|
+
self, source_url: str, *, idempotency_key: Optional[str] = None, dry_run: bool = False, **options: Any
|
|
103
|
+
) -> ImportResponse:
|
|
104
|
+
"""Has the API download a public HTTPS URL, then process it like an upload.
|
|
105
|
+
|
|
106
|
+
Options: ``file_name``, ``title``, ``description``, ``max_height``, ``trim``, ``redactions``,
|
|
107
|
+
``delete_original``, ``captions``, ``auto_redact``, ``metadata``.
|
|
108
|
+
"""
|
|
109
|
+
body = _body({"source_url": source_url, **options})
|
|
110
|
+
return _write(self._http, "/videos", body, dry_run=dry_run, idempotency_key=idempotency_key)
|
|
111
|
+
|
|
112
|
+
def upload_file(self, path: str, *, file_name: Optional[str] = None, **options: Any) -> CompleteResponse:
|
|
113
|
+
"""Uploads a local file end to end and returns the ``complete`` response; use ``wait_until_done`` next.
|
|
114
|
+
|
|
115
|
+
Create, then for each part: ask for its URL just before sending it (the URLs expire after 15 minutes) and PUT
|
|
116
|
+
its bytes, then complete. Options are those of ``upload_stream``.
|
|
117
|
+
"""
|
|
118
|
+
with open(path, "rb") as stream:
|
|
119
|
+
return self.upload_stream(stream, os.path.getsize(path), file_name or os.path.basename(path), **options)
|
|
120
|
+
|
|
121
|
+
def upload_bytes(self, data: bytes, file_name: str, **options: Any) -> CompleteResponse:
|
|
122
|
+
"""Like ``upload_file``, for bytes you already hold."""
|
|
123
|
+
return self.upload_stream(io.BytesIO(data), len(data), file_name, **options)
|
|
124
|
+
|
|
125
|
+
def upload_stream(
|
|
126
|
+
self,
|
|
127
|
+
stream: BinaryIO,
|
|
128
|
+
size_bytes: int,
|
|
129
|
+
file_name: str,
|
|
130
|
+
*,
|
|
131
|
+
idempotency_key: Optional[str] = None,
|
|
132
|
+
on_progress: Optional[Progress] = None,
|
|
133
|
+
title: Optional[str] = None,
|
|
134
|
+
description: Optional[str] = None,
|
|
135
|
+
max_height: Optional[int] = None,
|
|
136
|
+
metadata: Optional[Dict[str, str]] = None,
|
|
137
|
+
duration_seconds: Optional[float] = None,
|
|
138
|
+
**process_options: Any,
|
|
139
|
+
) -> CompleteResponse:
|
|
140
|
+
"""Uploads ``size_bytes`` read from a file-like object, one part at a time.
|
|
141
|
+
|
|
142
|
+
``idempotency_key`` makes the whole upload retry-safe: create uses it and complete uses ``<key>:complete``.
|
|
143
|
+
``on_progress`` is called after each part with ``videoId``, ``partNumber``, ``partCount``, ``bytesUploaded``
|
|
144
|
+
and ``totalBytes``. ``process_options`` (``trim``, ``redactions``, ``captions``, ``auto_redact``,
|
|
145
|
+
``delete_original``) are applied when processing starts.
|
|
146
|
+
"""
|
|
147
|
+
created = self.create(
|
|
148
|
+
file_name,
|
|
149
|
+
size_bytes,
|
|
150
|
+
idempotency_key=idempotency_key,
|
|
151
|
+
title=title,
|
|
152
|
+
description=description,
|
|
153
|
+
max_height=max_height,
|
|
154
|
+
metadata=metadata,
|
|
155
|
+
duration_seconds=duration_seconds,
|
|
156
|
+
)
|
|
157
|
+
video_id, part_count = created["videoId"], created["partCount"]
|
|
158
|
+
uploaded = 0
|
|
159
|
+
for number in range(1, part_count + 1):
|
|
160
|
+
part = self.get_part_urls(video_id, [number])["parts"][0]
|
|
161
|
+
self._http.put_part(part["url"], _read_exactly(stream, part["sizeBytes"]))
|
|
162
|
+
uploaded += part["sizeBytes"]
|
|
163
|
+
if on_progress:
|
|
164
|
+
on_progress(
|
|
165
|
+
{"videoId": video_id, "partNumber": number, "partCount": part_count, "bytesUploaded": uploaded, "totalBytes": size_bytes}
|
|
166
|
+
)
|
|
167
|
+
return self.complete(
|
|
168
|
+
video_id, idempotency_key=f"{idempotency_key}:complete" if idempotency_key else None, **process_options
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
def get_part_urls(
|
|
172
|
+
self,
|
|
173
|
+
video_id: str,
|
|
174
|
+
part_numbers: Optional[List[int]] = None,
|
|
175
|
+
*,
|
|
176
|
+
file: Optional[str] = None,
|
|
177
|
+
parts: Optional[List[Dict[str, int]]] = None,
|
|
178
|
+
) -> PartUrlsResponse:
|
|
179
|
+
"""Pre-signed URLs (valid 15 minutes) for up to 100 parts. Ask just before you upload each part.
|
|
180
|
+
|
|
181
|
+
``file="camera"`` is for the second file of a screen + camera upload; ``parts`` (``partNumber`` and
|
|
182
|
+
``sizeBytes`` each) is for streaming uploads.
|
|
183
|
+
"""
|
|
184
|
+
body = _body({"part_numbers": part_numbers, "file": file, "parts": parts})
|
|
185
|
+
return self._http.request("POST", f"/videos/{video_id}/parts", body=body, retry=True)
|
|
186
|
+
|
|
187
|
+
def complete(
|
|
188
|
+
self, video_id: str, *, idempotency_key: Optional[str] = None, dry_run: bool = False, **options: Any
|
|
189
|
+
) -> CompleteResponse:
|
|
190
|
+
"""Finishes the upload and starts processing. Calling it again never starts a second job.
|
|
191
|
+
|
|
192
|
+
Options: ``trim``, ``redactions``, ``captions``, ``auto_redact``, ``delete_original``, and for streaming
|
|
193
|
+
uploads ``source`` and ``camera`` (``sizeBytes`` and ``partCount``).
|
|
194
|
+
"""
|
|
195
|
+
return _write(
|
|
196
|
+
self._http, f"/videos/{video_id}/complete", _body(options),
|
|
197
|
+
dry_run=dry_run, idempotency_key=idempotency_key, safe_to_repeat=True,
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
def abort(self, video_id: str) -> Any:
|
|
201
|
+
"""Discards an unfinished upload."""
|
|
202
|
+
return self._http.request("POST", f"/videos/{video_id}/abort", retry=True)
|
|
203
|
+
|
|
204
|
+
def heartbeat(self, video_id: str) -> Any:
|
|
205
|
+
"""Keeps a pre-started recording job waiting; send one about every 10 seconds."""
|
|
206
|
+
return self._http.request("POST", f"/videos/{video_id}/heartbeat")
|
|
207
|
+
|
|
208
|
+
def get(self, video_id: str, *, link_ttl: Optional[int] = None) -> VideoStatus:
|
|
209
|
+
"""Status and progress, and (when finished) the download, playback and streaming links.
|
|
210
|
+
|
|
211
|
+
``link_ttl`` is how many seconds (300 to 86400) the playback and streaming links last.
|
|
212
|
+
"""
|
|
213
|
+
return self._http.request("GET", f"/videos/{video_id}", query={"linkTtl": link_ttl})
|
|
214
|
+
|
|
215
|
+
def list(
|
|
216
|
+
self, *, limit: Optional[int] = None, next_token: Optional[str] = None, metadata: Optional[Dict[str, str]] = None
|
|
217
|
+
) -> VideosPage:
|
|
218
|
+
"""One page of videos, newest first. Use ``list_all`` to go through every page.
|
|
219
|
+
|
|
220
|
+
``metadata`` filters (up to 5, AND'd together): ``metadata={"lessonId": "123"}``.
|
|
221
|
+
"""
|
|
222
|
+
query: Dict[str, Any] = {"limit": limit, "nextToken": next_token}
|
|
223
|
+
for name, value in (metadata or {}).items():
|
|
224
|
+
query[f"metadata.{name}"] = value
|
|
225
|
+
return self._http.request("GET", "/videos", query=query)
|
|
226
|
+
|
|
227
|
+
def list_all(self, *, limit: Optional[int] = None, metadata: Optional[Dict[str, str]] = None) -> Iterator[VideoSummary]:
|
|
228
|
+
"""Every video, newest first, fetching pages as you go: ``for video in client.videos.list_all(): ...``"""
|
|
229
|
+
next_token: Optional[str] = None
|
|
230
|
+
while True:
|
|
231
|
+
page = self.list(limit=limit, next_token=next_token, metadata=metadata)
|
|
232
|
+
yield from page.get("videos", [])
|
|
233
|
+
next_token = page.get("nextToken")
|
|
234
|
+
if not next_token:
|
|
235
|
+
return
|
|
236
|
+
|
|
237
|
+
def update(self, video_id: str, **fields: Any) -> Dict[str, Any]:
|
|
238
|
+
"""Changes ``title``, ``description`` or ``metadata``. ``None`` clears a field or a metadata key."""
|
|
239
|
+
return self._http.request("PATCH", f"/videos/{video_id}", body=_body(fields, keep_none=True))
|
|
240
|
+
|
|
241
|
+
def delete(self, video_id: str) -> Dict[str, Any]:
|
|
242
|
+
"""Removes the video and its files (irreversible)."""
|
|
243
|
+
return self._http.request("DELETE", f"/videos/{video_id}")
|
|
244
|
+
|
|
245
|
+
def cancel(self, video_id: str) -> Dict[str, Any]:
|
|
246
|
+
"""Stops processing."""
|
|
247
|
+
return self._http.request("POST", f"/videos/{video_id}/cancel", retry=True)
|
|
248
|
+
|
|
249
|
+
def reprocess(
|
|
250
|
+
self, video_id: str, *, idempotency_key: Optional[str] = None, dry_run: bool = False, **options: Any
|
|
251
|
+
) -> ReprocessResponse:
|
|
252
|
+
"""Processes the video again as a new video. Options are not inherited: send everything you want applied.
|
|
253
|
+
|
|
254
|
+
Options: ``max_height``, ``trim``, ``redactions``, ``captions``, ``auto_redact``, ``delete_original``,
|
|
255
|
+
``metadata``, ``title``, ``description``, ``layout``.
|
|
256
|
+
"""
|
|
257
|
+
return _write(
|
|
258
|
+
self._http, f"/videos/{video_id}/reprocess", _body(options), dry_run=dry_run, idempotency_key=idempotency_key
|
|
259
|
+
)
|
|
260
|
+
|
|
261
|
+
def wait_until_done(
|
|
262
|
+
self,
|
|
263
|
+
video_id: str,
|
|
264
|
+
*,
|
|
265
|
+
timeout: float = 1800.0,
|
|
266
|
+
interval: float = 3.0,
|
|
267
|
+
max_interval: float = 15.0,
|
|
268
|
+
on_status: Optional[Callable[[VideoStatus], None]] = None,
|
|
269
|
+
link_ttl: Optional[int] = None,
|
|
270
|
+
) -> VideoStatus:
|
|
271
|
+
"""Checks the status until the video reaches a final one and returns it.
|
|
272
|
+
|
|
273
|
+
Final: SUCCEEDED, FAILED, TIMED_OUT, ABORTED or HISTORY_UNAVAILABLE. It does not raise for FAILED, so check
|
|
274
|
+
``["status"]``. The pause between checks starts at ``interval`` seconds and grows by half each time up to
|
|
275
|
+
``max_interval``. Raises ``WaitTimeoutError`` after ``timeout`` seconds. Webhooks are better than polling in
|
|
276
|
+
production.
|
|
277
|
+
"""
|
|
278
|
+
deadline = time.monotonic() + timeout
|
|
279
|
+
pause = interval
|
|
280
|
+
while True:
|
|
281
|
+
status = self.get(video_id, link_ttl=link_ttl)
|
|
282
|
+
if on_status:
|
|
283
|
+
on_status(status)
|
|
284
|
+
if status.get("status") in FINAL_STATUSES:
|
|
285
|
+
return status
|
|
286
|
+
left = deadline - time.monotonic()
|
|
287
|
+
if left <= 0:
|
|
288
|
+
raise WaitTimeoutError(video_id, timeout, status.get("status"))
|
|
289
|
+
time.sleep(min(pause, left))
|
|
290
|
+
pause = min(max_interval, pause * 1.5)
|
|
291
|
+
|
|
292
|
+
def list_thumbnails(self, video_id: str) -> Dict[str, Any]:
|
|
293
|
+
"""Candidate thumbnail frames and the current choice, for a finished video."""
|
|
294
|
+
return self._http.request("GET", f"/videos/{video_id}/thumbnails")
|
|
295
|
+
|
|
296
|
+
def choose_thumbnail(
|
|
297
|
+
self,
|
|
298
|
+
video_id: str,
|
|
299
|
+
*,
|
|
300
|
+
candidate: Optional[int] = None,
|
|
301
|
+
time: Optional[float] = None,
|
|
302
|
+
image: Union[bytes, str, None] = None,
|
|
303
|
+
content_type: Optional[str] = None,
|
|
304
|
+
crop: Optional[Dict[str, float]] = None,
|
|
305
|
+
) -> Dict[str, Any]:
|
|
306
|
+
"""Chooses the thumbnail: a ``candidate`` index (0 is the automatic poster), any ``time`` of the video, or your
|
|
307
|
+
own ``image`` (JPEG or PNG bytes, with ``content_type``). ``crop`` (fractions ``x``, ``y``, ``w``, ``h``) goes
|
|
308
|
+
with a time or an image. A time or a crop is made by a short job: poll ``list_thumbnails`` until
|
|
309
|
+
``current.status`` is ``ready``.
|
|
310
|
+
"""
|
|
311
|
+
if isinstance(image, bytes):
|
|
312
|
+
image = base64.b64encode(image).decode("ascii")
|
|
313
|
+
body = _body({"candidate": candidate, "time": time, "image": image, "content_type": content_type, "crop": crop})
|
|
314
|
+
return self._http.request("POST", f"/videos/{video_id}/thumbnail", body=body, retry=True)
|
|
315
|
+
|
|
316
|
+
def get_chapters(self, video_id: str) -> Dict[str, Any]:
|
|
317
|
+
return self._http.request("GET", f"/videos/{video_id}/chapters")
|
|
318
|
+
|
|
319
|
+
def set_chapters(self, video_id: str, chapters: List[Dict[str, Any]]) -> Dict[str, Any]:
|
|
320
|
+
"""Replaces the chapters of a finished video (an empty list removes them): ``[{"start": 0, "title": "Intro"}]``."""
|
|
321
|
+
return self._http.request("PUT", f"/videos/{video_id}/chapters", body={"chapters": chapters})
|
|
322
|
+
|
|
323
|
+
def delete_chapters(self, video_id: str) -> Dict[str, Any]:
|
|
324
|
+
return self._http.request("DELETE", f"/videos/{video_id}/chapters")
|
|
325
|
+
|
|
326
|
+
def set_embed(self, video_id: str, enabled: bool) -> Dict[str, Any]:
|
|
327
|
+
"""Turns the embeddable player on or off. Turning it off revokes the code; turning it on again makes a new one."""
|
|
328
|
+
return self._http.request("PUT", f"/videos/{video_id}/embed", body={"enabled": enabled})
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
class Edits:
|
|
332
|
+
def __init__(self, http: HttpClient) -> None:
|
|
333
|
+
self._http = http
|
|
334
|
+
|
|
335
|
+
def create(
|
|
336
|
+
self, clips: List[Dict[str, Any]], *, idempotency_key: Optional[str] = None, dry_run: bool = False, **options: Any
|
|
337
|
+
) -> EditResponse:
|
|
338
|
+
"""Makes a new video from clips of your finished videos (trim, stitch, splice).
|
|
339
|
+
|
|
340
|
+
``clips`` are ``{"videoId": ..., "start": ..., "end": ..., "transition": {...}}``. Options: ``file_name``,
|
|
341
|
+
``title``, ``description``, ``max_height``, ``redactions``, ``captions``, ``auto_redact``, ``metadata``.
|
|
342
|
+
"""
|
|
343
|
+
body = {"edit": {"clips": clips}, **_body(options)}
|
|
344
|
+
return _write(self._http, "/videos", body, dry_run=dry_run, idempotency_key=idempotency_key)
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
class _Tokens:
|
|
348
|
+
"""Short-lived tokens that let a browser do one thing without your API key. Make them on your server."""
|
|
349
|
+
|
|
350
|
+
def __init__(self, http: HttpClient) -> None:
|
|
351
|
+
self._http = http
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
class UploadTokens(_Tokens):
|
|
355
|
+
def create(self, **options: Any) -> Dict[str, Any]:
|
|
356
|
+
"""Options: ``expires_in``, ``max_bytes``, ``max_seconds``, ``max_height``, ``captions``, ``auto_redact``, ``metadata``."""
|
|
357
|
+
return self._http.request("POST", "/upload-tokens", body=_body(options))
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
class EditTokens(_Tokens):
|
|
361
|
+
def create(self, video_ids: List[str], **options: Any) -> Dict[str, Any]:
|
|
362
|
+
"""Options: ``expires_in``, ``max_seconds``, ``title``, ``metadata``."""
|
|
363
|
+
return self._http.request("POST", "/edit-tokens", body=_body({"video_ids": video_ids, **options}))
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
class ThumbnailTokens(_Tokens):
|
|
367
|
+
def create(self, video_id: str, *, expires_in: Optional[int] = None) -> Dict[str, Any]:
|
|
368
|
+
return self._http.request("POST", "/thumbnail-tokens", body=_body({"video_id": video_id, "expires_in": expires_in}))
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
class UsageApi:
|
|
372
|
+
def __init__(self, http: HttpClient) -> None:
|
|
373
|
+
self._http = http
|
|
374
|
+
|
|
375
|
+
def get(self, month: Optional[str] = None) -> Usage:
|
|
376
|
+
"""Processing, storage and delivery for a ``YYYY-MM`` month (default: this one). Always empty with a test key."""
|
|
377
|
+
return self._http.request("GET", "/usage", query={"month": month})
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
class AccountApi:
|
|
381
|
+
def __init__(self, http: HttpClient) -> None:
|
|
382
|
+
self._http = http
|
|
383
|
+
|
|
384
|
+
def get(self) -> Dict[str, Any]:
|
|
385
|
+
"""The account, its plan and this month's usage."""
|
|
386
|
+
return self._http.request("GET", "/account")
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
class Webhooks:
|
|
390
|
+
def __init__(self, http: HttpClient) -> None:
|
|
391
|
+
self._http = http
|
|
392
|
+
|
|
393
|
+
def get(self) -> WebhookConfig:
|
|
394
|
+
return self._http.request("GET", "/webhook")
|
|
395
|
+
|
|
396
|
+
def set(self, url: str, *, events: Optional[List[str]] = None, enabled: Optional[bool] = None) -> WebhookConfig:
|
|
397
|
+
"""Sets the endpoint. The response carries the signing secret (``whsec_...``) for ``verify_webhook``."""
|
|
398
|
+
return self._http.request("PUT", "/webhook", body=_body({"url": url, "events": events, "enabled": enabled}))
|
|
399
|
+
|
|
400
|
+
def delete(self) -> Dict[str, Any]:
|
|
401
|
+
return self._http.request("DELETE", "/webhook")
|
|
402
|
+
|
|
403
|
+
def rotate_secret(self) -> WebhookConfig:
|
|
404
|
+
"""A new signing secret; the old one stops working at once."""
|
|
405
|
+
return self._http.request("POST", "/webhook/rotate")
|
|
406
|
+
|
|
407
|
+
def test(self) -> Dict[str, Any]:
|
|
408
|
+
"""Sends a test event and reports the endpoint's answer."""
|
|
409
|
+
return self._http.request("POST", "/webhook/test")
|
|
410
|
+
|
|
411
|
+
def deliveries(self, *, limit: Optional[int] = None) -> List[Dict[str, Any]]:
|
|
412
|
+
"""The last 30 days of delivery attempts."""
|
|
413
|
+
return self._http.request("GET", "/webhook/deliveries", query={"limit": limit})["deliveries"]
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
class LiveStreams:
|
|
417
|
+
def __init__(self, http: HttpClient) -> None:
|
|
418
|
+
self._http = http
|
|
419
|
+
|
|
420
|
+
def create(self, *, dry_run: bool = False, **options: Any) -> LiveStream:
|
|
421
|
+
"""Makes a stream to broadcast to: its ingest (RTMPS URL and stream key), a watch link and a playback link. Pro and Scale.
|
|
422
|
+
|
|
423
|
+
Options: ``title``, ``description``, ``metadata``, ``record``, ``captions``, ``auto_redact``, ``max_duration_minutes``.
|
|
424
|
+
"""
|
|
425
|
+
return _write(self._http, "/live-streams", _body(options), dry_run=dry_run)
|
|
426
|
+
|
|
427
|
+
def get(self, live_stream_id: str) -> LiveStream:
|
|
428
|
+
return self._http.request("GET", f"/live-streams/{live_stream_id}")
|
|
429
|
+
|
|
430
|
+
def list(self, *, limit: Optional[int] = None, next_token: Optional[str] = None) -> LiveStreamsPage:
|
|
431
|
+
return self._http.request("GET", "/live-streams", query={"limit": limit, "nextToken": next_token})
|
|
432
|
+
|
|
433
|
+
def list_all(self, *, limit: Optional[int] = None) -> Iterator[LiveStream]:
|
|
434
|
+
"""Every stream, newest first, fetching pages as you go."""
|
|
435
|
+
next_token: Optional[str] = None
|
|
436
|
+
while True:
|
|
437
|
+
page = self.list(limit=limit, next_token=next_token)
|
|
438
|
+
yield from page.get("liveStreams", [])
|
|
439
|
+
next_token = page.get("nextToken")
|
|
440
|
+
if not next_token:
|
|
441
|
+
return
|
|
442
|
+
|
|
443
|
+
def update(self, live_stream_id: str, **fields: Any) -> LiveStream:
|
|
444
|
+
"""Changes ``title``, ``description`` or ``metadata``. ``None`` clears a field or a metadata key."""
|
|
445
|
+
return self._http.request("PATCH", f"/live-streams/{live_stream_id}", body=_body(fields, keep_none=True))
|
|
446
|
+
|
|
447
|
+
def get_ingest(self, live_stream_id: str) -> LiveIngest:
|
|
448
|
+
"""The ingest URL and stream key again, while the stream is open."""
|
|
449
|
+
return self._http.request("GET", f"/live-streams/{live_stream_id}/ingest")["ingest"]
|
|
450
|
+
|
|
451
|
+
def end(self, live_stream_id: str) -> LiveStream:
|
|
452
|
+
"""Ends the broadcast now; the status becomes ``ended`` a moment later."""
|
|
453
|
+
return self._http.request("POST", f"/live-streams/{live_stream_id}/end")
|
|
454
|
+
|
|
455
|
+
def delete(self, live_stream_id: str) -> Dict[str, Any]:
|
|
456
|
+
"""Stops the stream if it is live and deletes it. A video already saved from its recording is kept."""
|
|
457
|
+
return self._http.request("DELETE", f"/live-streams/{live_stream_id}")
|
videopiper/types.py
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
"""Response shapes, from docs/openapi.yaml and docs/payloads.md.
|
|
2
|
+
|
|
3
|
+
Responses are plain dicts with the API's own (camelCase) keys; these TypedDicts describe them for type checkers.
|
|
4
|
+
Any field can be missing, so read the optional ones with ``.get``. Times are seconds.
|
|
5
|
+
"""
|
|
6
|
+
from typing import Any, Dict, List, TypedDict
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class CreateUploadResponse(TypedDict, total=False):
|
|
10
|
+
videoId: str
|
|
11
|
+
partSize: int # bytes per part (the last part may be smaller)
|
|
12
|
+
partCount: int
|
|
13
|
+
camera: Dict[str, int]
|
|
14
|
+
status: str
|
|
15
|
+
statusPath: str
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class ImportResponse(TypedDict, total=False):
|
|
19
|
+
videoId: str
|
|
20
|
+
status: str
|
|
21
|
+
fileName: str
|
|
22
|
+
statusPath: str
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class EditResponse(TypedDict, total=False):
|
|
26
|
+
videoId: str
|
|
27
|
+
clips: int
|
|
28
|
+
status: str
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class CompleteResponse(TypedDict, total=False):
|
|
32
|
+
videoId: str
|
|
33
|
+
status: str
|
|
34
|
+
statusPath: str
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class ReprocessResponse(TypedDict, total=False):
|
|
38
|
+
videoId: str
|
|
39
|
+
reprocessOf: str
|
|
40
|
+
status: str
|
|
41
|
+
statusPath: str
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class PartUrl(TypedDict):
|
|
45
|
+
partNumber: int
|
|
46
|
+
sizeBytes: int
|
|
47
|
+
url: str # pre-signed, valid 15 minutes: PUT the bytes with no Authorization header
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class PartUrlsResponse(TypedDict):
|
|
51
|
+
parts: List[PartUrl]
|
|
52
|
+
expiresInSeconds: int
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class VideoSummary(TypedDict, total=False):
|
|
56
|
+
videoId: str
|
|
57
|
+
fileName: str
|
|
58
|
+
title: str
|
|
59
|
+
description: str
|
|
60
|
+
createdAt: int
|
|
61
|
+
sizeBytes: int
|
|
62
|
+
state: str # UPLOADING, READY_TO_START or SUBMITTED
|
|
63
|
+
mode: str # live or test
|
|
64
|
+
clips: int
|
|
65
|
+
reprocessOf: str
|
|
66
|
+
metadata: Dict[str, str]
|
|
67
|
+
captions: bool
|
|
68
|
+
autoRedact: bool
|
|
69
|
+
embeddable: bool
|
|
70
|
+
outcome: str # SUCCEEDED, FAILED, TIMED_OUT or ABORTED; absent while it runs
|
|
71
|
+
endedAt: float
|
|
72
|
+
durationSeconds: float
|
|
73
|
+
height: int
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class VideosPage(TypedDict, total=False):
|
|
77
|
+
videos: List[VideoSummary]
|
|
78
|
+
nextToken: str
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class VideoStatus(TypedDict, total=False):
|
|
82
|
+
videoId: str
|
|
83
|
+
status: str # UPLOADING, READY_TO_START, SUBMITTED, RUNNING, SUCCEEDED, FAILED, TIMED_OUT, ABORTED, ...
|
|
84
|
+
mode: str
|
|
85
|
+
title: str
|
|
86
|
+
description: str
|
|
87
|
+
metadata: Dict[str, str]
|
|
88
|
+
deletesAt: int # test videos only
|
|
89
|
+
downloadUrl: str # pre-signed MP4 link (SUCCEEDED only)
|
|
90
|
+
downloadExpiresInSeconds: int
|
|
91
|
+
posterUrl: str
|
|
92
|
+
playbackUrl: str
|
|
93
|
+
posterPlaybackUrl: str
|
|
94
|
+
playbackExpiresAt: float
|
|
95
|
+
chaptersUrl: str
|
|
96
|
+
streamUrl: str # HLS master playlist (Pro and Scale)
|
|
97
|
+
streamExpiresAt: float
|
|
98
|
+
captions: Dict[str, Any]
|
|
99
|
+
autoRedaction: Dict[str, Any]
|
|
100
|
+
embed: Dict[str, Any]
|
|
101
|
+
thumbnail: Dict[str, Any]
|
|
102
|
+
startedAt: float
|
|
103
|
+
stoppedAt: float
|
|
104
|
+
progress: Dict[str, Any]
|
|
105
|
+
result: Dict[str, Any]
|
|
106
|
+
processing: Dict[str, Any]
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
class Usage(TypedDict, total=False):
|
|
110
|
+
month: str
|
|
111
|
+
complete: bool
|
|
112
|
+
processing: Dict[str, float]
|
|
113
|
+
storage: Dict[str, Any]
|
|
114
|
+
delivery: Dict[str, Any]
|
|
115
|
+
live: Dict[str, Any]
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
class WebhookConfig(TypedDict, total=False):
|
|
119
|
+
configured: bool
|
|
120
|
+
url: str
|
|
121
|
+
events: List[str]
|
|
122
|
+
enabled: bool
|
|
123
|
+
secret: str # whsec_ prefixed signing secret, for verify_webhook
|
|
124
|
+
createdAt: str
|
|
125
|
+
updatedAt: str
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
class LiveIngest(TypedDict):
|
|
129
|
+
rtmpsUrl: str
|
|
130
|
+
streamKey: str # lets whoever holds it broadcast to this stream: give it only to the broadcaster
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
class LiveStream(TypedDict, total=False):
|
|
134
|
+
liveStreamId: str
|
|
135
|
+
status: str # idle, live, ended, expired or failed
|
|
136
|
+
mode: str
|
|
137
|
+
title: str
|
|
138
|
+
description: str
|
|
139
|
+
metadata: Dict[str, str]
|
|
140
|
+
record: bool
|
|
141
|
+
maxDurationMinutes: int
|
|
142
|
+
createdAt: int
|
|
143
|
+
startedAt: int
|
|
144
|
+
endedAt: int
|
|
145
|
+
durationSeconds: int
|
|
146
|
+
viewers: int
|
|
147
|
+
videoId: str # the video saved from the recording
|
|
148
|
+
usage: Dict[str, float]
|
|
149
|
+
ingest: LiveIngest
|
|
150
|
+
watch: Dict[str, str]
|
|
151
|
+
playbackUrl: str
|
|
152
|
+
playbackExpiresAt: int
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
class LiveStreamsPage(TypedDict, total=False):
|
|
156
|
+
liveStreams: List[LiveStream]
|
|
157
|
+
nextToken: str
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
class WebhookEvent(TypedDict):
|
|
161
|
+
id: str
|
|
162
|
+
type: str
|
|
163
|
+
livemode: bool # False for events about test-mode videos
|
|
164
|
+
createdAt: str
|
|
165
|
+
data: Dict[str, Any]
|
videopiper/webhooks.py
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import base64
|
|
2
|
+
import hashlib
|
|
3
|
+
import hmac
|
|
4
|
+
import json
|
|
5
|
+
import time
|
|
6
|
+
from typing import Any, Dict, Mapping, Union
|
|
7
|
+
|
|
8
|
+
from .errors import WebhookVerificationError
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def verify_webhook(
|
|
12
|
+
secret: str,
|
|
13
|
+
body: Union[str, bytes],
|
|
14
|
+
headers: Mapping[str, str],
|
|
15
|
+
tolerance_seconds: int = 300,
|
|
16
|
+
) -> Dict[str, Any]:
|
|
17
|
+
"""Checks a webhook delivery and returns the parsed event.
|
|
18
|
+
|
|
19
|
+
Standard Webhooks: the signature is the base64 HMAC-SHA256 of ``"<id>.<timestamp>.<body>"`` with the bytes of the
|
|
20
|
+
secret after ``whsec_``, sent as ``v1,<signature>`` in ``webhook-signature`` (several may be sent, separated by
|
|
21
|
+
spaces). Pass the raw request body, before any JSON parsing. Raises ``WebhookVerificationError`` if a header is
|
|
22
|
+
missing, no ``v1`` signature matches, the timestamp is more than ``tolerance_seconds`` from now, or the body isn't JSON.
|
|
23
|
+
"""
|
|
24
|
+
lowered = {name.lower(): value for name, value in headers.items()}
|
|
25
|
+
event_id = lowered.get("webhook-id")
|
|
26
|
+
timestamp = lowered.get("webhook-timestamp")
|
|
27
|
+
signatures = lowered.get("webhook-signature")
|
|
28
|
+
if not event_id or not timestamp or not signatures:
|
|
29
|
+
raise WebhookVerificationError("Missing webhook-id, webhook-timestamp or webhook-signature header")
|
|
30
|
+
|
|
31
|
+
try:
|
|
32
|
+
sent_at = int(timestamp)
|
|
33
|
+
except ValueError:
|
|
34
|
+
raise WebhookVerificationError("Invalid webhook-timestamp header") from None
|
|
35
|
+
if abs(time.time() - sent_at) > tolerance_seconds:
|
|
36
|
+
raise WebhookVerificationError("Timestamp is outside the tolerance")
|
|
37
|
+
|
|
38
|
+
raw = body.encode("utf-8") if isinstance(body, str) else bytes(body)
|
|
39
|
+
key = base64.b64decode(secret[len("whsec_"):] if secret.startswith("whsec_") else secret)
|
|
40
|
+
digest = hmac.new(key, f"{event_id}.{timestamp}.".encode() + raw, hashlib.sha256).digest()
|
|
41
|
+
expected = base64.b64encode(digest)
|
|
42
|
+
|
|
43
|
+
valid = False
|
|
44
|
+
for entry in signatures.split(" "):
|
|
45
|
+
version, _, given = entry.partition(",")
|
|
46
|
+
# Look at every entry (no early exit), and compare in constant time.
|
|
47
|
+
if version == "v1" and hmac.compare_digest(given.encode(), expected):
|
|
48
|
+
valid = True
|
|
49
|
+
if not valid:
|
|
50
|
+
raise WebhookVerificationError("No matching signature")
|
|
51
|
+
|
|
52
|
+
try:
|
|
53
|
+
return json.loads(raw)
|
|
54
|
+
except ValueError:
|
|
55
|
+
raise WebhookVerificationError("Body is not valid JSON") from None
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: videopiper
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Official Python SDK for the Video Piper API: upload, import, edit and stream videos, with webhook verification.
|
|
5
|
+
Author: PodWorks3D LLC
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://videopiper.com/docs/
|
|
8
|
+
Keywords: video,api,sdk,video-api,transcoding,hls,webhooks,live-streaming
|
|
9
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
16
|
+
Classifier: Typing :: Typed
|
|
17
|
+
Requires-Python: >=3.9
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
License-File: LICENSE
|
|
20
|
+
Dynamic: license-file
|
|
21
|
+
|
|
22
|
+
# videopiper
|
|
23
|
+
|
|
24
|
+
The official Python SDK for the [Video Piper](https://videopiper.com/) API: upload or import videos, cut and join them, stream live, and check webhooks. Python 3.9 or newer, no dependencies (it uses the standard library), type hints included.
|
|
25
|
+
|
|
26
|
+
```sh
|
|
27
|
+
pip install videopiper
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
Keep your API key on your server. To let a browser upload, record or edit, make a short-lived token with the SDK and give the page that (see [Tokens for browsers](#tokens-for-browsers)).
|
|
31
|
+
|
|
32
|
+
## Quickstart
|
|
33
|
+
|
|
34
|
+
Upload a file, wait for it to process, and get the download link:
|
|
35
|
+
|
|
36
|
+
```python
|
|
37
|
+
from videopiper import VideoPiper
|
|
38
|
+
|
|
39
|
+
client = VideoPiper() # reads VIDEO_PIPER_API_KEY
|
|
40
|
+
|
|
41
|
+
upload = client.videos.upload_file("lesson.mp4", title="Lesson 1")
|
|
42
|
+
video = client.videos.wait_until_done(upload["videoId"])
|
|
43
|
+
|
|
44
|
+
if video["status"] == "SUCCEEDED":
|
|
45
|
+
print(video["downloadUrl"]) # an MP4 link, valid for 15 minutes
|
|
46
|
+
else:
|
|
47
|
+
print("Processing ended with", video["status"])
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
`upload_file` creates the video, then for each part asks for its upload URL just before sending that part (the URLs expire after 15 minutes), sends the bytes with no `Authorization` header, and completes the upload. Any file size works; parts go one at a time and only one part is held in memory.
|
|
51
|
+
|
|
52
|
+
## Setup
|
|
53
|
+
|
|
54
|
+
```python
|
|
55
|
+
client = VideoPiper(
|
|
56
|
+
api_key="vpk_...", # default: the VIDEO_PIPER_API_KEY environment variable
|
|
57
|
+
base_url="https://api.videopiper.dev/v1", # default: VIDEO_PIPER_API_URL, then this
|
|
58
|
+
timeout=60, # seconds, per request
|
|
59
|
+
max_retries=3, # see Retries
|
|
60
|
+
)
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
Other options: `upload_timeout` (per part, default 10 minutes), `retry_base_delay`, `max_retry_delay`, `requests_per_second` (default 5, the API's limit per key; 0 turns it off) and `transport` (an object with `send(method, url, headers, body, timeout)`, to use instead of `urllib`).
|
|
64
|
+
|
|
65
|
+
Arguments are Python names (`file_name`, `max_height`) and are sent with the API's camelCase names. Objects inside a request (`trim`, `redactions`, `clips`, `metadata`) are sent exactly as the [API docs](https://videopiper.com/docs/) show them. Responses are plain dicts with the API's keys; `videopiper.types` has `TypedDict`s for the main ones.
|
|
66
|
+
|
|
67
|
+
## Videos
|
|
68
|
+
|
|
69
|
+
```python
|
|
70
|
+
# Upload from disk, from bytes, or from a file-like object
|
|
71
|
+
client.videos.upload_file("lesson.mp4", max_height=720, captions=True, on_progress=print)
|
|
72
|
+
client.videos.upload_bytes(data, "clip.mp4", metadata={"lessonId": "123"})
|
|
73
|
+
client.videos.upload_stream(stream, size_bytes, "recording.webm")
|
|
74
|
+
|
|
75
|
+
# Have the API download a public HTTPS URL
|
|
76
|
+
video = client.videos.import_url("https://example.com/tutorial.mp4", max_height=1080)
|
|
77
|
+
|
|
78
|
+
# Status, and the links once it has finished
|
|
79
|
+
status = client.videos.get(video["videoId"]) # playbackUrl, streamUrl, downloadUrl, posterUrl
|
|
80
|
+
done = client.videos.wait_until_done(video["videoId"], timeout=600, on_status=lambda s: print(s["status"]))
|
|
81
|
+
|
|
82
|
+
# Lists: one page, or every video
|
|
83
|
+
page = client.videos.list(limit=25, metadata={"lessonId": "123"})
|
|
84
|
+
for item in client.videos.list_all(limit=100):
|
|
85
|
+
print(item["videoId"], item.get("outcome"))
|
|
86
|
+
|
|
87
|
+
client.videos.update(video_id, title="New title", metadata={"archived": None}) # None clears
|
|
88
|
+
client.videos.reprocess(video_id, max_height=2160, trim={"start": 30}) # a new video from the same upload
|
|
89
|
+
client.videos.cancel(video_id) # stop processing
|
|
90
|
+
client.videos.delete(video_id)
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
`wait_until_done` checks the status with a growing pause (3 s up to 15 s) until the video reaches a final status and returns it; `FAILED`, `TIMED_OUT` and `ABORTED` are returned, not raised, so look at `["status"]`. It raises `WaitTimeoutError` after `timeout` seconds (default 30 minutes). In production prefer [webhooks](#webhooks) to polling.
|
|
94
|
+
|
|
95
|
+
The steps behind `upload_file` are public too, for streaming uploads and other special cases: `create`, `get_part_urls`, `complete`, `abort` and `heartbeat`.
|
|
96
|
+
|
|
97
|
+
Also on `client.videos`: `list_thumbnails`, `choose_thumbnail`, `get_chapters`, `set_chapters`, `delete_chapters` and `set_embed`.
|
|
98
|
+
|
|
99
|
+
## Edits
|
|
100
|
+
|
|
101
|
+
Make a new video from clips of finished videos: trim, stitch and splice.
|
|
102
|
+
|
|
103
|
+
```python
|
|
104
|
+
edit = client.edits.create(
|
|
105
|
+
[
|
|
106
|
+
{"videoId": intro_id, "end": 60},
|
|
107
|
+
{"videoId": lesson_id, "start": 90, "transition": {"type": "fade", "seconds": 0.75}},
|
|
108
|
+
],
|
|
109
|
+
file_name="Lesson 1 (final)",
|
|
110
|
+
)
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
## Dry runs and idempotency
|
|
114
|
+
|
|
115
|
+
`dry_run=True` checks a request without creating or starting anything (it doesn't count toward your daily limit) and returns `{"valid": True, "dryRun": True, ...}`; an edit also reports its length. It works on `videos.create`, `import_url`, `complete`, `reprocess`, `edits.create` and `live_streams.create`.
|
|
116
|
+
|
|
117
|
+
```python
|
|
118
|
+
check = client.edits.create(clips, dry_run=True)
|
|
119
|
+
print(check["durationSeconds"])
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
An `idempotency_key` makes a retry safe: the same key with the same request returns the first response again (kept 24 hours) instead of creating a second video.
|
|
123
|
+
|
|
124
|
+
```python
|
|
125
|
+
client.videos.import_url(url, idempotency_key=f"import-{lesson_id}")
|
|
126
|
+
client.videos.upload_file(path, idempotency_key=f"upload-{lesson_id}") # complete uses "<key>:complete"
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
## Tokens for browsers
|
|
130
|
+
|
|
131
|
+
```python
|
|
132
|
+
upload = client.upload_tokens.create(max_seconds=600, metadata={"userId": "u_1"})
|
|
133
|
+
# <video-piper-recorder upload-token="{upload['uploadToken']}">
|
|
134
|
+
edit = client.edit_tokens.create([a, b], max_seconds=300)
|
|
135
|
+
thumb = client.thumbnail_tokens.create(video_id)
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
These go with the web components in [`@videopiper/elements`](https://www.npmjs.com/package/@videopiper/elements).
|
|
139
|
+
|
|
140
|
+
## Usage and account
|
|
141
|
+
|
|
142
|
+
```python
|
|
143
|
+
usage = client.usage.get("2026-10") # processing, storage and delivery against the plan
|
|
144
|
+
account = client.account.get()
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
## Live streams (Pro and Scale)
|
|
148
|
+
|
|
149
|
+
```python
|
|
150
|
+
stream = client.live_streams.create(title="Product demo", record=True, max_duration_minutes=60)
|
|
151
|
+
print(stream["ingest"]["rtmpsUrl"], stream["ingest"]["streamKey"]) # for OBS: give the key only to the broadcaster
|
|
152
|
+
print(stream["watch"]["url"]) # for viewers
|
|
153
|
+
client.live_streams.end(stream["liveStreamId"]) # the recording becomes a normal video
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
Also `get`, `list`, `list_all`, `update`, `get_ingest` and `delete`.
|
|
157
|
+
|
|
158
|
+
## Webhooks
|
|
159
|
+
|
|
160
|
+
Set the endpoint, and keep the signing secret it returns:
|
|
161
|
+
|
|
162
|
+
```python
|
|
163
|
+
config = client.webhooks.set("https://example.com/hooks/video-piper", events=["video.ready", "video.failed"])
|
|
164
|
+
# config["secret"] is "whsec_..."
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
Check every delivery with `verify_webhook` on the **raw** body (before JSON parsing). It returns the parsed event, or raises `WebhookVerificationError` if a header is missing, no signature matches (it accepts several, separated by spaces), or the timestamp is more than 5 minutes off (change it with `tolerance_seconds`).
|
|
168
|
+
|
|
169
|
+
```python
|
|
170
|
+
from flask import Flask, request
|
|
171
|
+
from videopiper import WebhookVerificationError, verify_webhook
|
|
172
|
+
|
|
173
|
+
app = Flask(__name__)
|
|
174
|
+
|
|
175
|
+
@app.post("/hooks/video-piper")
|
|
176
|
+
def video_piper_hook():
|
|
177
|
+
try:
|
|
178
|
+
event = verify_webhook(WEBHOOK_SECRET, request.get_data(), request.headers)
|
|
179
|
+
except WebhookVerificationError:
|
|
180
|
+
return "", 401
|
|
181
|
+
if event["type"] == "video.ready":
|
|
182
|
+
print("ready", event["data"]["videoId"])
|
|
183
|
+
return "", 204
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
Deliveries are at-least-once: deduplicate on `event["id"]` (the `webhook-id` header). Also on `client.webhooks`: `get`, `delete`, `rotate_secret`, `test` and `deliveries`.
|
|
187
|
+
|
|
188
|
+
## Errors
|
|
189
|
+
|
|
190
|
+
Every failed call raises `VideoPiperError`:
|
|
191
|
+
|
|
192
|
+
```python
|
|
193
|
+
from videopiper import VideoPiperError
|
|
194
|
+
|
|
195
|
+
try:
|
|
196
|
+
client.videos.upload_file("lesson.mp4")
|
|
197
|
+
except VideoPiperError as error:
|
|
198
|
+
print(error.status, error.code, error.message, error.request_id)
|
|
199
|
+
if error.code == "PLAN_LIMIT_REACHED":
|
|
200
|
+
... # out of Free minutes
|
|
201
|
+
for problem in error.details or []: # INVALID_REQUEST lists every problem
|
|
202
|
+
print(problem["field"], problem["message"])
|
|
203
|
+
raise
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
Program against `code` (`PLAN_LIMIT_REACHED`, `DAILY_LIMIT`, `RATE_LIMITED`, `NOT_FOUND`, ...), not the message. Quote `request_id` when asking for help. A request that got no answer raises `VideoPiperConnectionError` (`status` 0, `code` `CONNECTION_ERROR`); `wait_until_done` running out of time raises `WaitTimeoutError`. All three are `VideoPiperError`s.
|
|
207
|
+
|
|
208
|
+
## Retries and rate limits
|
|
209
|
+
|
|
210
|
+
Failed requests are sent again, up to `max_retries` times with exponential backoff and jitter, when it is safe: network errors, `RATE_LIMITED` (429) and 500, 502, 503 or 504, on `GET`, `PUT`, `PATCH` and `DELETE`, on the idempotent `POST`s (part URLs, `complete`, `cancel`, `abort`) and on any `POST` that carries an `idempotency_key`. Daily, quota and plan limits are not retried. A `Retry-After` header is honoured (a wait longer than `max_retry_delay`, 60 s by default, raises instead).
|
|
211
|
+
|
|
212
|
+
The client sends at most 5 requests a second (the API's limit per key); lower or raise it with `requests_per_second`.
|
|
213
|
+
|
|
214
|
+
## Test mode
|
|
215
|
+
|
|
216
|
+
A test key (`vp_test_...`, made in the app under Account, API keys, Create test key) works the same way and is free: it only sees test videos, which are one MP4 of at most 60 seconds and 480p, marked TEST, and deleted after 24 hours. Use it in tests and CI. A test key can't read the account or change the webhook, and `usage.get` is always empty for it.
|
|
217
|
+
|
|
218
|
+
```python
|
|
219
|
+
client = VideoPiper(os.environ["VIDEO_PIPER_TEST_KEY"])
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
Test webhook events carry `livemode: false`.
|
|
223
|
+
|
|
224
|
+
## License
|
|
225
|
+
|
|
226
|
+
MIT. Docs: <https://videopiper.com/docs/>.
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
videopiper/__init__.py,sha256=ZCicOehvhUhfn1cmUd8Lx1qJAPlNc73TDxTxIA5NESs,519
|
|
2
|
+
videopiper/_http.py,sha256=9O6bPtrxyd5xzkU_8-CUz7XRLnLY2N4gZLfo51n5l2Y,7867
|
|
3
|
+
videopiper/_version.py,sha256=kUR5RAFc7HCeiqdlX36dZOHkUI5wI6V_43RpEcD8b-0,22
|
|
4
|
+
videopiper/client.py,sha256=8nWkyvyD6Z4Rasx5XUQE2Pdc7Lpzr6wYL9jWDBWXhOk,2837
|
|
5
|
+
videopiper/errors.py,sha256=_HiRvXGLMjye2ubheqOdMOg0DyCaoOYj8J6a75ldY_8,1920
|
|
6
|
+
videopiper/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
7
|
+
videopiper/resources.py,sha256=zxBkI6KfPgpLpnx3PIIthg_PVL1vAWQ44_zX0bTmovw,20514
|
|
8
|
+
videopiper/types.py,sha256=2mU9qlHu1hdORM0lotRvK1bwuiWqgjjOYofk7ntmC7o,4018
|
|
9
|
+
videopiper/webhooks.py,sha256=O9ItdAh2KPkmch8O_61hFWuFrCN_ZIdkqrrS8lpqafU,2328
|
|
10
|
+
videopiper-0.1.0.dist-info/licenses/LICENSE,sha256=XD6DG1HT_5S0gosCdylX4sFrsEXT0WeCv_JBXPCIWeE,1071
|
|
11
|
+
videopiper-0.1.0.dist-info/METADATA,sha256=fGs92dqZGLwhVnmfmtQMRJ9oWOTOnKCg-ujHzw85opQ,10327
|
|
12
|
+
videopiper-0.1.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
|
|
13
|
+
videopiper-0.1.0.dist-info/top_level.txt,sha256=koOBjpRzXI5eYlIdKxWqu332HyXONcqBkl5Mp-ta1KQ,11
|
|
14
|
+
videopiper-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 PodWorks3D LLC
|
|
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
|
+
videopiper
|