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 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
@@ -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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.2.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -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