videofetch-sdk 0.2.0__tar.gz → 0.3.0__tar.gz
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.
- videofetch_sdk-0.3.0/CHANGELOG.md +68 -0
- {videofetch_sdk-0.2.0 → videofetch_sdk-0.3.0}/PKG-INFO +19 -1
- {videofetch_sdk-0.2.0 → videofetch_sdk-0.3.0}/README.md +18 -0
- {videofetch_sdk-0.2.0 → videofetch_sdk-0.3.0}/pyproject.toml +1 -1
- {videofetch_sdk-0.2.0 → videofetch_sdk-0.3.0}/src/videofetch/__init__.py +18 -1
- {videofetch_sdk-0.2.0 → videofetch_sdk-0.3.0}/src/videofetch/client.py +14 -0
- {videofetch_sdk-0.2.0 → videofetch_sdk-0.3.0}/src/videofetch/errors.py +38 -0
- {videofetch_sdk-0.2.0 → videofetch_sdk-0.3.0}/src/videofetch/models.py +114 -5
- {videofetch_sdk-0.2.0 → videofetch_sdk-0.3.0}/src/videofetch/resources.py +184 -2
- {videofetch_sdk-0.2.0 → videofetch_sdk-0.3.0}/src/videofetch/webhooks.py +45 -1
- videofetch_sdk-0.3.0/tests/test_download_to.py +306 -0
- {videofetch_sdk-0.2.0 → videofetch_sdk-0.3.0}/tests/test_errors_v2.py +2 -2
- {videofetch_sdk-0.2.0 → videofetch_sdk-0.3.0}/tests/test_sdk.py +32 -0
- {videofetch_sdk-0.2.0 → videofetch_sdk-0.3.0}/tests/test_usage.py +17 -0
- videofetch_sdk-0.3.0/tests/test_webhooks.py +78 -0
- {videofetch_sdk-0.2.0 → videofetch_sdk-0.3.0}/tests/test_webhooks_resource.py +121 -1
- videofetch_sdk-0.2.0/CHANGELOG.md +0 -34
- videofetch_sdk-0.2.0/tests/test_webhooks.py +0 -45
- {videofetch_sdk-0.2.0 → videofetch_sdk-0.3.0}/.gitignore +0 -0
- {videofetch_sdk-0.2.0 → videofetch_sdk-0.3.0}/src/videofetch/asyncio.py +0 -0
- {videofetch_sdk-0.2.0 → videofetch_sdk-0.3.0}/tests/integration_live.py +0 -0
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.3.0 — 2026-09-22
|
|
4
|
+
|
|
5
|
+
Added
|
|
6
|
+
- **Webhook delivery inspection:** `client.webhooks.deliveries(endpoint_id, limit=50)` returns a
|
|
7
|
+
`WebhookDeliveriesResult` with the endpoint's current state, aggregate `health`
|
|
8
|
+
(`total`/`succeeded`/`dead`/`pending`/`success_rate`, plus `max_attempts` and
|
|
9
|
+
`retry_schedule_seconds`), and the recent `items` (each a `WebhookDelivery`).
|
|
10
|
+
- **Webhook replay:** `client.webhooks.replay(event_id)` re-queues a past event and returns a
|
|
11
|
+
`ReplayResult(queued, event_id)`. A `NotFoundError` means the delivery is not visible to your
|
|
12
|
+
account.
|
|
13
|
+
- **Idempotency helpers:** `delivery_id(headers)` and `attempt_number(headers)` read the new
|
|
14
|
+
`X-VideoFetch-Delivery` and `X-VideoFetch-Attempt` headers case-insensitively.
|
|
15
|
+
- **Queue hints on queued downloads:** `Download.queue_position` (1 == next to be claimed),
|
|
16
|
+
`ahead_of_you`, and `estimated_wait_seconds`. All three are populated only while
|
|
17
|
+
`status == "queued"` and are `None` in every other state.
|
|
18
|
+
- **Per-key usage attribution:** `Usage.key_used_bytes` / `Usage.key_used_gb` report this key's own
|
|
19
|
+
accumulated usage, alongside the account-level figures.
|
|
20
|
+
- **Save the result locally:** `client.downloads.download_to(download_id, path=None)` (and the async
|
|
21
|
+
`await client.downloads.download_to(...)`) streams a completed job's file to disk in chunks and
|
|
22
|
+
returns the absolute path of the saved file. With no destination configured for the job the
|
|
23
|
+
platform issues a time-limited, self-authorizing `download_url`, and this method saves it for you —
|
|
24
|
+
to `<sanitized title or job id>.<mp3|mp4>` in the current directory by default, or to the file path
|
|
25
|
+
(or existing directory) you pass. The API key is never sent to that link; if it has expired the
|
|
26
|
+
server re-signs it on the next job fetch and the save is retried once. Preconditions are typed:
|
|
27
|
+
a non-completed job raises `DownloadNotCompletedError`, and a completed job without a
|
|
28
|
+
`download_url` raises `DownloadURLUnavailableError`.
|
|
29
|
+
|
|
30
|
+
Changed (breaking)
|
|
31
|
+
- **Attempt records no longer expose a raw egress host.** `DownloadAttempt.egress` replaces the
|
|
32
|
+
0.2.0 host field and carries a neutral label: `"relay"` when the attempt was served through an
|
|
33
|
+
alternate route, `null` for a direct attempt. Internal egress hosts are not part of the public
|
|
34
|
+
contract.
|
|
35
|
+
- **The strategy vocabulary is now `"direct" | "relay" | "relay_secondary"`** for both
|
|
36
|
+
`Download.strategy` and `DownloadAttempt.strategy`. Any value outside the vocabulary is
|
|
37
|
+
normalised to `"relay"`.
|
|
38
|
+
- Attempt `error_message` strings are scrubbed of egress addresses and credentials before they are
|
|
39
|
+
returned to you.
|
|
40
|
+
- **`Usage.used_bytes` / `Usage.used_gb` are now account-level.** They report the whole account's
|
|
41
|
+
current-month billable usage (the quota is account-level and shared by every key), not the calling
|
|
42
|
+
key's usage. Use the new `key_used_bytes` / `key_used_gb` for per-key attribution.
|
|
43
|
+
- **Downloads are account-level visible.** `GET /v1/downloads` (list) and
|
|
44
|
+
`GET /v1/downloads/{id}` (detail) now return every job owned by the account, regardless of which
|
|
45
|
+
key created it; any key in the account may also cancel/delete any of the account's jobs.
|
|
46
|
+
- **Webhook delivery semantics changed.** Deliveries are now **at-least-once**: the same event may be
|
|
47
|
+
delivered more than once. Retries are capped at **3 attempts** (the first send counts as attempt 1;
|
|
48
|
+
the previous limit was 5) with a 30s → 300s backoff (±20% jitter). **4xx responses are permanent
|
|
49
|
+
failures and are not retried**, except `408` and `429`. **Ordering is not guaranteed** — sort by
|
|
50
|
+
each event's `seq`. New headers accompany every attempt:
|
|
51
|
+
- `X-VideoFetch-Delivery` — stable per-event idempotency key.
|
|
52
|
+
- `X-VideoFetch-Attempt` — the attempt number (1 = first).
|
|
53
|
+
- `X-VideoFetch-Timestamp` — when the attempt was sent.
|
|
54
|
+
`X-VideoFetch-Event` and `X-VideoFetch-Signature` are unchanged.
|
|
55
|
+
|
|
56
|
+
Migration
|
|
57
|
+
- If you read the raw host field from 0.2.0, read `egress` instead — it is a label, not a hostname.
|
|
58
|
+
- Branch on `strategy == "direct"` vs anything else. Pin `videofetch-sdk==0.2.*` if you need the old
|
|
59
|
+
response shape while you migrate.
|
|
60
|
+
- **Deduplicate webhooks by `X-VideoFetch-Delivery`.** Because delivery is at-least-once, persist the
|
|
61
|
+
delivery id (e.g. a unique index) and skip events you have already processed. Use
|
|
62
|
+
`videofetch.delivery_id(request.headers)` and `videofetch.attempt_number(request.headers)` in your
|
|
63
|
+
handler, and order events by `seq` rather than arrival time.
|
|
64
|
+
- If you displayed `used_gb` as a per-key number, switch to `key_used_gb` for that, or keep
|
|
65
|
+
`used_gb` and relabel it as account-level month usage.
|
|
66
|
+
- Downloads created by other keys in your account may now appear in list/detail results. If you need
|
|
67
|
+
per-key filtering, record the creating key on your side.
|
|
68
|
+
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: videofetch-sdk
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: Official VideoFetch SDK for Python — video ingestion API (YouTube URL → MP4/MP3 to your storage)
|
|
5
5
|
Project-URL: Homepage, https://github.com/heavenlxj/videofetch-sdk/tree/main/python
|
|
6
6
|
Project-URL: Documentation, https://videofetch.dev/docs
|
|
@@ -59,6 +59,22 @@ print(result.size_bytes, result.cost_usd)
|
|
|
59
59
|
result = client.downloads.create_and_wait(url=..., format="1080p")
|
|
60
60
|
```
|
|
61
61
|
|
|
62
|
+
Save the artifact locally. Created with no destination configured, the job completes
|
|
63
|
+
with a time-limited `download_url`; `download_to` streams it to disk and returns the
|
|
64
|
+
absolute path of the saved file:
|
|
65
|
+
|
|
66
|
+
```python
|
|
67
|
+
path = client.downloads.download_to(result.id) # ./<title>.mp4 in the current dir
|
|
68
|
+
path = client.downloads.download_to(result.id, "out/clip.mp4") # explicit file path
|
|
69
|
+
path = client.downloads.download_to(result.id, "out/") # existing directory
|
|
70
|
+
print(path) # "/abs/path/out/clip.mp4"
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
The default name is the sanitized title (or the job id) plus `.mp3` for `format="mp3"`
|
|
74
|
+
and `.mp4` otherwise. The self-authorizing link is fetched without your API key, and an
|
|
75
|
+
expired link is re-signed and retried once. A job that is not `completed` raises
|
|
76
|
+
`DownloadNotCompletedError`.
|
|
77
|
+
|
|
62
78
|
Async (FastAPI / Next.js backends):
|
|
63
79
|
|
|
64
80
|
```python
|
|
@@ -114,6 +130,8 @@ All SDK errors derive from `videofetch.VideoFetchError`:
|
|
|
114
130
|
| `NotFoundError` | job/connection not found (404) |
|
|
115
131
|
| `RateLimitError` | slow down (429) |
|
|
116
132
|
| `JobFailedError` | job reached `failed` — **never charged** |
|
|
133
|
+
| `DownloadNotCompletedError` | `download_to()` called before the job is `completed` |
|
|
134
|
+
| `DownloadURLUnavailableError` | completed job has no `download_url` (bucket destination) |
|
|
117
135
|
|
|
118
136
|
## Serverless warning
|
|
119
137
|
|
|
@@ -33,6 +33,22 @@ print(result.size_bytes, result.cost_usd)
|
|
|
33
33
|
result = client.downloads.create_and_wait(url=..., format="1080p")
|
|
34
34
|
```
|
|
35
35
|
|
|
36
|
+
Save the artifact locally. Created with no destination configured, the job completes
|
|
37
|
+
with a time-limited `download_url`; `download_to` streams it to disk and returns the
|
|
38
|
+
absolute path of the saved file:
|
|
39
|
+
|
|
40
|
+
```python
|
|
41
|
+
path = client.downloads.download_to(result.id) # ./<title>.mp4 in the current dir
|
|
42
|
+
path = client.downloads.download_to(result.id, "out/clip.mp4") # explicit file path
|
|
43
|
+
path = client.downloads.download_to(result.id, "out/") # existing directory
|
|
44
|
+
print(path) # "/abs/path/out/clip.mp4"
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
The default name is the sanitized title (or the job id) plus `.mp3` for `format="mp3"`
|
|
48
|
+
and `.mp4` otherwise. The self-authorizing link is fetched without your API key, and an
|
|
49
|
+
expired link is re-signed and retried once. A job that is not `completed` raises
|
|
50
|
+
`DownloadNotCompletedError`.
|
|
51
|
+
|
|
36
52
|
Async (FastAPI / Next.js backends):
|
|
37
53
|
|
|
38
54
|
```python
|
|
@@ -88,6 +104,8 @@ All SDK errors derive from `videofetch.VideoFetchError`:
|
|
|
88
104
|
| `NotFoundError` | job/connection not found (404) |
|
|
89
105
|
| `RateLimitError` | slow down (429) |
|
|
90
106
|
| `JobFailedError` | job reached `failed` — **never charged** |
|
|
107
|
+
| `DownloadNotCompletedError` | `download_to()` called before the job is `completed` |
|
|
108
|
+
| `DownloadURLUnavailableError` | completed job has no `download_url` (bucket destination) |
|
|
91
109
|
|
|
92
110
|
## Serverless warning
|
|
93
111
|
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "videofetch-sdk"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.3.0"
|
|
8
8
|
description = "Official VideoFetch SDK for Python — video ingestion API (YouTube URL → MP4/MP3 to your storage)"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.9"
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
job = client.downloads.create(url="https://youtu.be/...", format="1080p")
|
|
6
6
|
result = job.wait() # polls until completed/failed
|
|
7
7
|
print(result.download_url) # presigned 7-day link (url destination)
|
|
8
|
+
path = client.downloads.download_to(result.id) # save the file locally → abs path
|
|
8
9
|
|
|
9
10
|
Usage & alerts (v0.2.0):
|
|
10
11
|
usage = client.usage.get() # quota_gb, used_pct, alert_level, ...
|
|
@@ -29,6 +30,8 @@ from .client import AsyncVideoFetch, VideoFetch # noqa: F401
|
|
|
29
30
|
from .errors import ( # noqa: F401
|
|
30
31
|
ApiError,
|
|
31
32
|
AuthenticationError,
|
|
33
|
+
DownloadNotCompletedError,
|
|
34
|
+
DownloadURLUnavailableError,
|
|
32
35
|
JobFailedError,
|
|
33
36
|
NotFoundError,
|
|
34
37
|
PermissionDeniedError,
|
|
@@ -44,33 +47,47 @@ from .models import ( # noqa: F401
|
|
|
44
47
|
DownloadAttempt,
|
|
45
48
|
DownloadList,
|
|
46
49
|
FormatInfo,
|
|
50
|
+
ReplayResult,
|
|
47
51
|
TrimSpec,
|
|
48
52
|
Usage,
|
|
49
53
|
UsageAlerts,
|
|
50
54
|
VideoInfo,
|
|
55
|
+
WebhookDeliveriesResult,
|
|
56
|
+
WebhookDelivery,
|
|
57
|
+
WebhookDeliveryHealth,
|
|
51
58
|
WebhookEndpoint,
|
|
52
59
|
WebhookList,
|
|
53
60
|
WebhookTestResult,
|
|
54
61
|
)
|
|
55
62
|
from .resources import DownloadJob # noqa: F401
|
|
56
63
|
from .webhooks import ( # noqa: F401
|
|
64
|
+
ATTEMPT_HEADER,
|
|
65
|
+
DELIVERY_HEADER,
|
|
66
|
+
TIMESTAMP_HEADER,
|
|
57
67
|
WEBHOOK_EVENTS,
|
|
58
68
|
SignatureVerificationError,
|
|
69
|
+
attempt_number,
|
|
59
70
|
compute_signature,
|
|
60
71
|
construct_event,
|
|
72
|
+
delivery_id,
|
|
61
73
|
verify_webhook_signature,
|
|
62
74
|
)
|
|
63
75
|
|
|
64
|
-
__version__ = "0.
|
|
76
|
+
__version__ = "0.3.0"
|
|
65
77
|
__all__ = [
|
|
66
78
|
"VideoFetch", "AsyncVideoFetch", "DownloadJob",
|
|
67
79
|
"VideoFetchError", "AuthenticationError", "PermissionDeniedError", "QuotaExceededError",
|
|
68
80
|
"ValidationError", "NotFoundError", "RateLimitError", "ApiError", "JobFailedError",
|
|
81
|
+
"DownloadNotCompletedError", "DownloadURLUnavailableError",
|
|
69
82
|
"Download", "DownloadList", "DownloadAttempt", "TrimSpec", "FormatInfo", "VideoInfo",
|
|
70
83
|
# v0.2.0
|
|
71
84
|
"Usage", "UsageAlerts", "AlertState", "AlertEvent",
|
|
72
85
|
"WebhookEndpoint", "WebhookList", "WebhookTestResult",
|
|
73
86
|
"WEBHOOK_EVENTS", "SignatureVerificationError",
|
|
74
87
|
"compute_signature", "construct_event", "verify_webhook_signature",
|
|
88
|
+
# v0.3.0
|
|
89
|
+
"WebhookDelivery", "WebhookDeliveriesResult", "WebhookDeliveryHealth", "ReplayResult",
|
|
90
|
+
"delivery_id", "attempt_number",
|
|
91
|
+
"DELIVERY_HEADER", "ATTEMPT_HEADER", "TIMESTAMP_HEADER",
|
|
75
92
|
"__version__",
|
|
76
93
|
]
|
|
@@ -106,6 +106,16 @@ class VideoFetch:
|
|
|
106
106
|
return _parse_body(resp)
|
|
107
107
|
raise VideoFetchError("request failed") # pragma: no cover
|
|
108
108
|
|
|
109
|
+
def open_stream(self, url: str) -> httpx.Response:
|
|
110
|
+
"""Open a streaming GET to an absolute URL WITHOUT the API key.
|
|
111
|
+
|
|
112
|
+
Download links are self-authorizing (time-limited), so the client's
|
|
113
|
+
default headers are intentionally not attached: the request is built
|
|
114
|
+
directly and sent through this client's transport. The caller owns the
|
|
115
|
+
returned response and must close it.
|
|
116
|
+
"""
|
|
117
|
+
return self._client.send(httpx.Request("GET", url), stream=True)
|
|
118
|
+
|
|
109
119
|
def close(self) -> None:
|
|
110
120
|
self._client.close()
|
|
111
121
|
|
|
@@ -177,6 +187,10 @@ class AsyncVideoFetch:
|
|
|
177
187
|
return _parse_body(resp)
|
|
178
188
|
raise VideoFetchError("request failed") # pragma: no cover
|
|
179
189
|
|
|
190
|
+
async def open_stream(self, url: str) -> httpx.Response:
|
|
191
|
+
"""Async twin of :meth:`VideoFetch.open_stream` — no API key is sent."""
|
|
192
|
+
return await self._client.send(httpx.Request("GET", url), stream=True)
|
|
193
|
+
|
|
180
194
|
async def close(self) -> None:
|
|
181
195
|
await self._client.aclose()
|
|
182
196
|
|
|
@@ -12,6 +12,11 @@ v0.2.0 error semantics:
|
|
|
12
12
|
402 QuotaExceededError — carries remaining_gb.
|
|
13
13
|
400 ValidationError — code ∈ {invalid_format, invalid_trim, invalid_url, invalid_webhook};
|
|
14
14
|
carries param.
|
|
15
|
+
|
|
16
|
+
v0.3.0 error semantics:
|
|
17
|
+
download_to() local-save preconditions:
|
|
18
|
+
DownloadNotCompletedError — status is not "completed" yet (code=job_not_completed).
|
|
19
|
+
DownloadURLUnavailableError — completed but no download_url (code=download_url_unavailable).
|
|
15
20
|
"""
|
|
16
21
|
|
|
17
22
|
from __future__ import annotations
|
|
@@ -118,6 +123,39 @@ class JobFailedError(VideoFetchError):
|
|
|
118
123
|
self.failed_not_charged = True
|
|
119
124
|
|
|
120
125
|
|
|
126
|
+
class DownloadNotCompletedError(VideoFetchError):
|
|
127
|
+
"""The job has not reached `completed`, so there is no artifact to save yet.
|
|
128
|
+
|
|
129
|
+
Wait for the terminal state first (``job.wait()`` or the ``download.completed``
|
|
130
|
+
webhook) and call ``download_to()`` afterwards.
|
|
131
|
+
"""
|
|
132
|
+
|
|
133
|
+
def __init__(self, *, job_id: str, status: str):
|
|
134
|
+
super().__init__(
|
|
135
|
+
f"Download job {job_id} is not ready to save: status is {status!r} "
|
|
136
|
+
"(expected 'completed'). Wait for the job to finish before downloading it.",
|
|
137
|
+
code="job_not_completed",
|
|
138
|
+
)
|
|
139
|
+
self.job_id = job_id
|
|
140
|
+
self.status = status
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
class DownloadURLUnavailableError(VideoFetchError):
|
|
144
|
+
"""The completed job exposes no downloadable link.
|
|
145
|
+
|
|
146
|
+
This happens when the file was delivered straight to your own bucket
|
|
147
|
+
(``destination_type`` other than ``url``) — read it from your storage instead.
|
|
148
|
+
"""
|
|
149
|
+
|
|
150
|
+
def __init__(self, *, job_id: str):
|
|
151
|
+
super().__init__(
|
|
152
|
+
f"Download job {job_id} completed but has no download_url "
|
|
153
|
+
"(it was delivered to your own storage destination).",
|
|
154
|
+
code="download_url_unavailable",
|
|
155
|
+
)
|
|
156
|
+
self.job_id = job_id
|
|
157
|
+
|
|
158
|
+
|
|
121
159
|
def _parse_retry_after(headers) -> Optional[float]:
|
|
122
160
|
"""Retry-After is either delta-seconds or an HTTP date; SDK handles seconds."""
|
|
123
161
|
if not headers:
|
|
@@ -43,8 +43,8 @@ class TrimSpec:
|
|
|
43
43
|
@dataclass
|
|
44
44
|
class DownloadAttempt:
|
|
45
45
|
attempt_no: Optional[int] = None
|
|
46
|
-
strategy: Optional[str] = None # direct |
|
|
47
|
-
|
|
46
|
+
strategy: Optional[str] = None # direct | relay | relay_secondary
|
|
47
|
+
egress: Optional[str] = None # "relay" or None (direct); real egress hosts are never returned
|
|
48
48
|
result: Optional[str] = None
|
|
49
49
|
error_code: Optional[str] = None
|
|
50
50
|
error_message: Optional[str] = None
|
|
@@ -58,7 +58,7 @@ class DownloadAttempt:
|
|
|
58
58
|
return None
|
|
59
59
|
return cls(
|
|
60
60
|
attempt_no=_as_int(d.get("attempt_no")), strategy=d.get("strategy"),
|
|
61
|
-
|
|
61
|
+
egress=d.get("egress"), result=d.get("result"),
|
|
62
62
|
error_code=d.get("error_code"), error_message=d.get("error_message"),
|
|
63
63
|
latency_ms=_as_int(d.get("latency_ms")), started_at=d.get("started_at"),
|
|
64
64
|
finished_at=d.get("finished_at"),
|
|
@@ -95,6 +95,10 @@ class Download:
|
|
|
95
95
|
completed_at: Optional[str] = None
|
|
96
96
|
estimated_bytes: Optional[int] = None
|
|
97
97
|
attempt_details: list[DownloadAttempt] = field(default_factory=list)
|
|
98
|
+
# Queue position hints — only populated while status == "queued"; null otherwise.
|
|
99
|
+
queue_position: Optional[int] = None # 1 == next to be claimed
|
|
100
|
+
ahead_of_you: Optional[int] = None
|
|
101
|
+
estimated_wait_seconds: Optional[int] = None
|
|
98
102
|
|
|
99
103
|
@property
|
|
100
104
|
def is_terminal(self) -> bool:
|
|
@@ -125,6 +129,9 @@ class Download:
|
|
|
125
129
|
estimated_bytes=_as_int(d.get("estimated_bytes")),
|
|
126
130
|
attempt_details=[DownloadAttempt.from_dict(a) or DownloadAttempt()
|
|
127
131
|
for a in (d.get("attempt_details") or [])],
|
|
132
|
+
queue_position=_as_int(d.get("queue_position")),
|
|
133
|
+
ahead_of_you=_as_int(d.get("ahead_of_you")),
|
|
134
|
+
estimated_wait_seconds=_as_int(d.get("estimated_wait_seconds")),
|
|
128
135
|
)
|
|
129
136
|
|
|
130
137
|
|
|
@@ -182,14 +189,18 @@ class VideoInfo:
|
|
|
182
189
|
class Usage:
|
|
183
190
|
"""GET /v1/usage — account quota snapshot + live alert level + concurrency.
|
|
184
191
|
|
|
185
|
-
`used_bytes`/`used_gb` are
|
|
186
|
-
is
|
|
192
|
+
`used_bytes`/`used_gb` are the ACCOUNT-WIDE current-month billable usage (the
|
|
193
|
+
quota is account-level and shared by every key). `key_used_bytes`/`key_used_gb`
|
|
194
|
+
are this key's own accumulated usage, for per-key attribution.
|
|
195
|
+
`account_used_*_month` mirror the account-level current-month figures.
|
|
187
196
|
"""
|
|
188
197
|
key_id: str = ""
|
|
189
198
|
plan: str = "free"
|
|
190
199
|
quota_gb: Optional[float] = None
|
|
191
200
|
used_bytes: int = 0
|
|
192
201
|
used_gb: float = 0.0
|
|
202
|
+
key_used_bytes: int = 0
|
|
203
|
+
key_used_gb: float = 0.0
|
|
193
204
|
remaining_gb: Optional[float] = None
|
|
194
205
|
payg_balance_cents: int = 0
|
|
195
206
|
payg_rate_usd_per_gb: float = 0.5
|
|
@@ -209,6 +220,8 @@ class Usage:
|
|
|
209
220
|
quota_gb=_as_float(d.get("quota_gb")),
|
|
210
221
|
used_bytes=int(d.get("used_bytes") or 0),
|
|
211
222
|
used_gb=float(d.get("used_gb") or 0.0),
|
|
223
|
+
key_used_bytes=int(d.get("key_used_bytes") or 0),
|
|
224
|
+
key_used_gb=float(d.get("key_used_gb") or 0.0),
|
|
212
225
|
remaining_gb=_as_float(d.get("remaining_gb")),
|
|
213
226
|
payg_balance_cents=int(d.get("payg_balance_cents") or 0),
|
|
214
227
|
payg_rate_usd_per_gb=float(d.get("payg_rate_usd_per_gb") or 0.0),
|
|
@@ -361,3 +374,99 @@ class WebhookTestResult:
|
|
|
361
374
|
signature_header=d.get("signature_header", ""),
|
|
362
375
|
signature_format=d.get("signature_format", ""),
|
|
363
376
|
)
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
# ──────────────────── Webhook deliveries + replay (v0.3.0) ───────────────────
|
|
380
|
+
@dataclass
|
|
381
|
+
class WebhookDeliveryHealth:
|
|
382
|
+
"""Aggregate delivery health for one endpoint (GET /v1/webhooks/{id}/deliveries)."""
|
|
383
|
+
total: int = 0
|
|
384
|
+
succeeded: int = 0
|
|
385
|
+
dead: int = 0
|
|
386
|
+
pending: int = 0
|
|
387
|
+
success_rate: Optional[float] = None
|
|
388
|
+
|
|
389
|
+
@classmethod
|
|
390
|
+
def from_dict(cls, d: Optional[dict]) -> "WebhookDeliveryHealth":
|
|
391
|
+
d = d or {}
|
|
392
|
+
return cls(
|
|
393
|
+
total=int(d.get("total") or 0), succeeded=int(d.get("succeeded") or 0),
|
|
394
|
+
dead=int(d.get("dead") or 0), pending=int(d.get("pending") or 0),
|
|
395
|
+
success_rate=_as_float(d.get("success_rate")),
|
|
396
|
+
)
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
@dataclass
|
|
400
|
+
class WebhookDelivery:
|
|
401
|
+
"""A single webhook delivery attempt (or its final state).
|
|
402
|
+
|
|
403
|
+
Deliveries are at-least-once: retried up to `max_attempts` (3) with backoff
|
|
404
|
+
on transient errors; 4xx responses other than 408/429 are permanent failures
|
|
405
|
+
that are not retried. Ordering is NOT guaranteed — sort by `seq`.
|
|
406
|
+
"""
|
|
407
|
+
event_id: str = "" # idempotency key (X-VideoFetch-Delivery)
|
|
408
|
+
event: str = ""
|
|
409
|
+
seq: int = 0
|
|
410
|
+
status: str = "pending" # pending | succeeded | dead
|
|
411
|
+
attempts: int = 0
|
|
412
|
+
max_attempts: int = 3
|
|
413
|
+
last_status_code: Optional[int] = None
|
|
414
|
+
last_error: Optional[str] = None
|
|
415
|
+
next_attempt_at: Optional[str] = None
|
|
416
|
+
created_at: Optional[str] = None
|
|
417
|
+
delivered_at: Optional[str] = None
|
|
418
|
+
download_id: Optional[str] = None
|
|
419
|
+
|
|
420
|
+
@classmethod
|
|
421
|
+
def from_dict(cls, d: dict) -> "WebhookDelivery":
|
|
422
|
+
return cls(
|
|
423
|
+
event_id=str(d.get("event_id", "")), event=d.get("event", ""),
|
|
424
|
+
seq=int(d.get("seq") or 0), status=d.get("status", "pending"),
|
|
425
|
+
attempts=int(d.get("attempts") or 0),
|
|
426
|
+
max_attempts=int(d.get("max_attempts") or 0),
|
|
427
|
+
last_status_code=_as_int(d.get("last_status_code")),
|
|
428
|
+
last_error=_as_str(d.get("last_error")),
|
|
429
|
+
next_attempt_at=_as_str(d.get("next_attempt_at")),
|
|
430
|
+
created_at=_as_str(d.get("created_at")),
|
|
431
|
+
delivered_at=_as_str(d.get("delivered_at")),
|
|
432
|
+
download_id=_as_str(d.get("download_id")),
|
|
433
|
+
)
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
@dataclass
|
|
437
|
+
class WebhookDeliveriesResult:
|
|
438
|
+
"""GET /v1/webhooks/{endpoint_id}/deliveries — endpoint state + recent deliveries."""
|
|
439
|
+
endpoint_id: str = ""
|
|
440
|
+
active: bool = True
|
|
441
|
+
auto_disabled_at: Optional[str] = None
|
|
442
|
+
consecutive_failures: int = 0
|
|
443
|
+
max_attempts: int = 3
|
|
444
|
+
retry_schedule_seconds: str = "" # e.g. "30,300"
|
|
445
|
+
health: WebhookDeliveryHealth = field(default_factory=WebhookDeliveryHealth)
|
|
446
|
+
items: list[WebhookDelivery] = field(default_factory=list)
|
|
447
|
+
|
|
448
|
+
@classmethod
|
|
449
|
+
def from_dict(cls, d: dict) -> "WebhookDeliveriesResult":
|
|
450
|
+
return cls(
|
|
451
|
+
endpoint_id=str(d.get("endpoint_id", "")), active=bool(d.get("active", True)),
|
|
452
|
+
auto_disabled_at=_as_str(d.get("auto_disabled_at")),
|
|
453
|
+
consecutive_failures=int(d.get("consecutive_failures") or 0),
|
|
454
|
+
max_attempts=int(d.get("max_attempts") or 0),
|
|
455
|
+
retry_schedule_seconds=_as_str(d.get("retry_schedule_seconds")) or "",
|
|
456
|
+
health=WebhookDeliveryHealth.from_dict(d.get("health")),
|
|
457
|
+
items=[WebhookDelivery.from_dict(i) for i in (d.get("items") or [])],
|
|
458
|
+
)
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
@dataclass
|
|
462
|
+
class ReplayResult:
|
|
463
|
+
"""POST /v1/webhooks/deliveries/{event_id}/replay result.
|
|
464
|
+
|
|
465
|
+
A 404 means the delivery is not visible to this account.
|
|
466
|
+
"""
|
|
467
|
+
queued: bool = False
|
|
468
|
+
event_id: str = ""
|
|
469
|
+
|
|
470
|
+
@classmethod
|
|
471
|
+
def from_dict(cls, d: dict) -> "ReplayResult":
|
|
472
|
+
return cls(queued=bool(d.get("queued")), event_id=str(d.get("event_id", "")))
|