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.
@@ -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.2.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.2.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.2.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 | decodo_isp | decodo_dc
47
- proxy_host: Optional[str] = None
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
- proxy_host=d.get("proxy_host"), result=d.get("result"),
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 this key's lifetime usage; `account_used_*_month`
186
- is the account-wide current-month billable usage (the quota is account-level).
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", "")))