transcodely 0.3.7__tar.gz → 0.3.9__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.
Files changed (102) hide show
  1. {transcodely-0.3.7 → transcodely-0.3.9}/PKG-INFO +169 -6
  2. {transcodely-0.3.7 → transcodely-0.3.9}/README.md +164 -1
  3. {transcodely-0.3.7 → transcodely-0.3.9}/pyproject.toml +7 -5
  4. {transcodely-0.3.7 → transcodely-0.3.9}/src/transcodely/__init__.py +8 -0
  5. {transcodely-0.3.7 → transcodely-0.3.9}/src/transcodely/client.py +9 -0
  6. transcodely-0.3.9/src/transcodely/resources/billing.py +266 -0
  7. {transcodely-0.3.7 → transcodely-0.3.9}/src/transcodely/resources/events.py +12 -2
  8. transcodely-0.3.9/src/transcodely/resources/ingest_rules.py +183 -0
  9. {transcodely-0.3.7 → transcodely-0.3.9}/src/transcodely/resources/webhook_endpoints.py +12 -2
  10. {transcodely-0.3.7 → transcodely-0.3.9}/src/transcodely/types.py +119 -5
  11. {transcodely-0.3.7 → transcodely-0.3.9}/src/transcodely/v1/api_key_pb2.py +19 -19
  12. transcodely-0.3.9/src/transcodely/v1/app_pb2.py +129 -0
  13. {transcodely-0.3.7 → transcodely-0.3.9}/src/transcodely/v1/app_pb2.pyi +36 -2
  14. transcodely-0.3.9/src/transcodely/v1/billing_pb2.py +109 -0
  15. {transcodely-0.3.7 → transcodely-0.3.9}/src/transcodely/v1/billing_pb2.pyi +148 -2
  16. {transcodely-0.3.7 → transcodely-0.3.9}/src/transcodely/v1/common_pb2.py +5 -1
  17. {transcodely-0.3.7 → transcodely-0.3.9}/src/transcodely/v1/common_pb2.pyi +30 -0
  18. {transcodely-0.3.7 → transcodely-0.3.9}/src/transcodely/v1/content_aware_pb2.py +2 -2
  19. transcodely-0.3.9/src/transcodely/v1/ingest_rule_pb2.py +157 -0
  20. transcodely-0.3.9/src/transcodely/v1/ingest_rule_pb2.pyi +305 -0
  21. transcodely-0.3.9/src/transcodely/v1/job_pb2.py +250 -0
  22. {transcodely-0.3.7 → transcodely-0.3.9}/src/transcodely/v1/job_pb2.pyi +102 -2
  23. transcodely-0.3.9/src/transcodely/v1/organization_pb2.py +81 -0
  24. {transcodely-0.3.7 → transcodely-0.3.9}/src/transcodely/v1/organization_pb2.pyi +4 -2
  25. {transcodely-0.3.7 → transcodely-0.3.9}/src/transcodely/v1/streaming_pb2.py +9 -9
  26. transcodely-0.3.9/src/transcodely/v1/subtitles_pb2.py +75 -0
  27. transcodely-0.3.9/src/transcodely/v1/video_pb2.py +210 -0
  28. {transcodely-0.3.7 → transcodely-0.3.9}/src/transcodely/v1/video_pb2.pyi +6 -2
  29. {transcodely-0.3.7 → transcodely-0.3.9}/src/transcodely/v1/webhook_pb2.py +4 -4
  30. {transcodely-0.3.7 → transcodely-0.3.9}/src/transcodely/version.py +1 -1
  31. transcodely-0.3.7/src/transcodely/resources/billing.py +0 -138
  32. transcodely-0.3.7/src/transcodely/v1/app_pb2.py +0 -111
  33. transcodely-0.3.7/src/transcodely/v1/billing_pb2.py +0 -81
  34. transcodely-0.3.7/src/transcodely/v1/job_pb2.py +0 -236
  35. transcodely-0.3.7/src/transcodely/v1/organization_pb2.py +0 -81
  36. transcodely-0.3.7/src/transcodely/v1/subtitles_pb2.py +0 -75
  37. transcodely-0.3.7/src/transcodely/v1/video_pb2.py +0 -210
  38. {transcodely-0.3.7 → transcodely-0.3.9}/.gitignore +0 -0
  39. {transcodely-0.3.7 → transcodely-0.3.9}/LICENSE +0 -0
  40. {transcodely-0.3.7 → transcodely-0.3.9}/src/buf/__init__.py +0 -0
  41. {transcodely-0.3.7 → transcodely-0.3.9}/src/buf/validate/__init__.py +0 -0
  42. {transcodely-0.3.7 → transcodely-0.3.9}/src/buf/validate/validate_pb2.py +0 -0
  43. {transcodely-0.3.7 → transcodely-0.3.9}/src/buf/validate/validate_pb2.pyi +0 -0
  44. {transcodely-0.3.7 → transcodely-0.3.9}/src/transcodely/_codec/__init__.py +0 -0
  45. {transcodely-0.3.7 → transcodely-0.3.9}/src/transcodely/_codec/json_codec.py +0 -0
  46. {transcodely-0.3.7 → transcodely-0.3.9}/src/transcodely/_transport/__init__.py +0 -0
  47. {transcodely-0.3.7 → transcodely-0.3.9}/src/transcodely/_transport/error_mapping.py +0 -0
  48. {transcodely-0.3.7 → transcodely-0.3.9}/src/transcodely/_transport/headers.py +0 -0
  49. {transcodely-0.3.7 → transcodely-0.3.9}/src/transcodely/_transport/transport.py +0 -0
  50. {transcodely-0.3.7 → transcodely-0.3.9}/src/transcodely/errors.py +0 -0
  51. {transcodely-0.3.7 → transcodely-0.3.9}/src/transcodely/pagination.py +0 -0
  52. {transcodely-0.3.7 → transcodely-0.3.9}/src/transcodely/py.typed +0 -0
  53. {transcodely-0.3.7 → transcodely-0.3.9}/src/transcodely/resources/__init__.py +0 -0
  54. {transcodely-0.3.7 → transcodely-0.3.9}/src/transcodely/resources/_helpers.py +0 -0
  55. {transcodely-0.3.7 → transcodely-0.3.9}/src/transcodely/resources/api_keys.py +0 -0
  56. {transcodely-0.3.7 → transcodely-0.3.9}/src/transcodely/resources/apps.py +0 -0
  57. {transcodely-0.3.7 → transcodely-0.3.9}/src/transcodely/resources/health.py +0 -0
  58. {transcodely-0.3.7 → transcodely-0.3.9}/src/transcodely/resources/jobs.py +0 -0
  59. {transcodely-0.3.7 → transcodely-0.3.9}/src/transcodely/resources/memberships.py +0 -0
  60. {transcodely-0.3.7 → transcodely-0.3.9}/src/transcodely/resources/organizations.py +0 -0
  61. {transcodely-0.3.7 → transcodely-0.3.9}/src/transcodely/resources/origins.py +0 -0
  62. {transcodely-0.3.7 → transcodely-0.3.9}/src/transcodely/resources/presets.py +0 -0
  63. {transcodely-0.3.7 → transcodely-0.3.9}/src/transcodely/resources/users.py +0 -0
  64. {transcodely-0.3.7 → transcodely-0.3.9}/src/transcodely/resources/videos.py +0 -0
  65. {transcodely-0.3.7 → transcodely-0.3.9}/src/transcodely/streaming.py +0 -0
  66. {transcodely-0.3.7 → transcodely-0.3.9}/src/transcodely/v1/api_key_pb2.pyi +0 -0
  67. {transcodely-0.3.7 → transcodely-0.3.9}/src/transcodely/v1/codec_av1_pb2.py +0 -0
  68. {transcodely-0.3.7 → transcodely-0.3.9}/src/transcodely/v1/codec_av1_pb2.pyi +0 -0
  69. {transcodely-0.3.7 → transcodely-0.3.9}/src/transcodely/v1/codec_h264_pb2.py +0 -0
  70. {transcodely-0.3.7 → transcodely-0.3.9}/src/transcodely/v1/codec_h264_pb2.pyi +0 -0
  71. {transcodely-0.3.7 → transcodely-0.3.9}/src/transcodely/v1/codec_h265_pb2.py +0 -0
  72. {transcodely-0.3.7 → transcodely-0.3.9}/src/transcodely/v1/codec_h265_pb2.pyi +0 -0
  73. {transcodely-0.3.7 → transcodely-0.3.9}/src/transcodely/v1/codec_vp9_pb2.py +0 -0
  74. {transcodely-0.3.7 → transcodely-0.3.9}/src/transcodely/v1/codec_vp9_pb2.pyi +0 -0
  75. {transcodely-0.3.7 → transcodely-0.3.9}/src/transcodely/v1/content_aware_pb2.pyi +0 -0
  76. {transcodely-0.3.7 → transcodely-0.3.9}/src/transcodely/v1/drm_pb2.py +0 -0
  77. {transcodely-0.3.7 → transcodely-0.3.9}/src/transcodely/v1/drm_pb2.pyi +0 -0
  78. {transcodely-0.3.7 → transcodely-0.3.9}/src/transcodely/v1/hdr_pb2.py +0 -0
  79. {transcodely-0.3.7 → transcodely-0.3.9}/src/transcodely/v1/hdr_pb2.pyi +0 -0
  80. {transcodely-0.3.7 → transcodely-0.3.9}/src/transcodely/v1/health_pb2.py +0 -0
  81. {transcodely-0.3.7 → transcodely-0.3.9}/src/transcodely/v1/health_pb2.pyi +0 -0
  82. {transcodely-0.3.7 → transcodely-0.3.9}/src/transcodely/v1/media_pb2.py +0 -0
  83. {transcodely-0.3.7 → transcodely-0.3.9}/src/transcodely/v1/media_pb2.pyi +0 -0
  84. {transcodely-0.3.7 → transcodely-0.3.9}/src/transcodely/v1/membership_pb2.py +0 -0
  85. {transcodely-0.3.7 → transcodely-0.3.9}/src/transcodely/v1/membership_pb2.pyi +0 -0
  86. {transcodely-0.3.7 → transcodely-0.3.9}/src/transcodely/v1/origin_pb2.py +0 -0
  87. {transcodely-0.3.7 → transcodely-0.3.9}/src/transcodely/v1/origin_pb2.pyi +0 -0
  88. {transcodely-0.3.7 → transcodely-0.3.9}/src/transcodely/v1/preset_pb2.py +0 -0
  89. {transcodely-0.3.7 → transcodely-0.3.9}/src/transcodely/v1/preset_pb2.pyi +0 -0
  90. {transcodely-0.3.7 → transcodely-0.3.9}/src/transcodely/v1/streaming_pb2.pyi +0 -0
  91. {transcodely-0.3.7 → transcodely-0.3.9}/src/transcodely/v1/subtitles_pb2.pyi +0 -0
  92. {transcodely-0.3.7 → transcodely-0.3.9}/src/transcodely/v1/thumbnails_pb2.py +0 -0
  93. {transcodely-0.3.7 → transcodely-0.3.9}/src/transcodely/v1/thumbnails_pb2.pyi +0 -0
  94. {transcodely-0.3.7 → transcodely-0.3.9}/src/transcodely/v1/user_pb2.py +0 -0
  95. {transcodely-0.3.7 → transcodely-0.3.9}/src/transcodely/v1/user_pb2.pyi +0 -0
  96. {transcodely-0.3.7 → transcodely-0.3.9}/src/transcodely/v1/watermark_pb2.py +0 -0
  97. {transcodely-0.3.7 → transcodely-0.3.9}/src/transcodely/v1/watermark_pb2.pyi +0 -0
  98. {transcodely-0.3.7 → transcodely-0.3.9}/src/transcodely/v1/webhook_pb2.pyi +0 -0
  99. {transcodely-0.3.7 → transcodely-0.3.9}/src/transcodely/webhooks/__init__.py +0 -0
  100. {transcodely-0.3.7 → transcodely-0.3.9}/src/transcodely/webhooks/construct_event.py +0 -0
  101. {transcodely-0.3.7 → transcodely-0.3.9}/src/transcodely/webhooks/signature.py +0 -0
  102. {transcodely-0.3.7 → transcodely-0.3.9}/src/transcodely/webhooks/types.py +0 -0
@@ -1,15 +1,15 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: transcodely
3
- Version: 0.3.7
4
- Summary: Official Python SDK for the Transcodely video transcoding API
5
- Project-URL: Homepage, https://transcodely.com
6
- Project-URL: Documentation, https://transcodely.com/docs
3
+ Version: 0.3.9
4
+ Summary: Official Python SDK for Transcodely — encode video to HLS, DASH and MP4 into your own S3, GCS or R2 bucket, with DRM and signed playback.
5
+ Project-URL: Homepage, https://www.transcodely.com
6
+ Project-URL: Documentation, https://www.transcodely.com/docs
7
7
  Project-URL: Repository, https://github.com/transcodely/transcodely-python
8
8
  Project-URL: Issues, https://github.com/transcodely/transcodely-python/issues
9
9
  Author: Transcodely
10
10
  License-Expression: MIT
11
11
  License-File: LICENSE
12
- Keywords: connect-rpc,dash,encoding,hls,streaming,transcodely,transcoding,video
12
+ Keywords: abr,cmaf,connect-rpc,dash,drm,encoding,fairplay,ffmpeg,hls,r2,s3,signed-urls,streaming,transcodely,transcoding,video,video-hosting,widevine
13
13
  Classifier: Development Status :: 3 - Alpha
14
14
  Classifier: Intended Audience :: Developers
15
15
  Classifier: License :: OSI Approved :: MIT License
@@ -37,7 +37,9 @@ Description-Content-Type: text/markdown
37
37
 
38
38
  # transcodely
39
39
 
40
- Official Python SDK for the [Transcodely](https://transcodely.com) video transcoding API.
40
+ Official Python SDK for [Transcodely](https://www.transcodely.com) — encode video into
41
+ HLS, DASH and MP4 and write it to your own S3, GCS or R2 bucket, with DRM, signed
42
+ playback and deterministic output paths. Or let Transcodely host and deliver it.
41
43
 
42
44
  ```bash
43
45
  pip install transcodely
@@ -75,6 +77,49 @@ with Transcodely(api_key=os.environ["TRANSCODELY_API_KEY"]) as client:
75
77
 
76
78
  The simplified-string form (`"hls"`, `"h264"`, `"1080p"`) is what the API actually emits over the wire — the SDK round-trips it transparently to and from the proto enum integers.
77
79
 
80
+ ## Read the output report
81
+
82
+ Every completed output carries a report of what the produced file actually
83
+ turned out to be — measured from the encoded file rather than copied from the
84
+ request — plus the verdict of comparing those measurements against what was
85
+ asked for.
86
+
87
+ ```python
88
+ from transcodely.types import OutputReport
89
+
90
+ done = client.jobs.get(job.id)
91
+
92
+ for output in done.outputs:
93
+ if not output.HasField("report"):
94
+ continue # not measured — never "nothing wrong"
95
+
96
+ report: OutputReport = output.report
97
+ print(
98
+ output.id,
99
+ report.video.codec,
100
+ f"{report.video.width}x{report.video.height}",
101
+ f"{report.duration_seconds}s",
102
+ )
103
+
104
+ if not report.verdict.matches_request:
105
+ for m in report.verdict.mismatches:
106
+ print(f" {m.field}: asked for {m.expected}, got {m.actual}")
107
+ ```
108
+
109
+ Check `output.HasField("report")` rather than truthiness: an absent report means
110
+ "not measured", which is not the same as a report that found nothing wrong.
111
+ Branch on `m.field` — it comes from a fixed vocabulary (`video.codec`,
112
+ `video.resolution`, `duration_seconds`, …) — rather than on the values beside
113
+ it. For an ABR ladder the facts describe the highest-resolution rendition, the
114
+ same one the verdict judges; per-rendition detail stays in `variant_results`.
115
+
116
+ An output encoded with per-title content-aware analysis also carries
117
+ `report.content_aware`: the VMAF target the search aimed at, the score it reached
118
+ on its samples, and the CRF it chose. It describes the SEARCH, not the delivered
119
+ file — `vmaf_achieved` scores short samples taken before the real encode, which
120
+ is never scored itself. Check `report.HasField("content_aware")`; an ordinary
121
+ output has none.
122
+
78
123
  ## AI captions
79
124
 
80
125
  Add auto-generated captions to any output with a `generate` subtitle track. Leave `language` empty (or set `"auto"`) to auto-detect the spoken language, or pass an ISO 639-2 code to force one. A per-job fee is metered by source minute and surfaced on `job.fees`; produced captions appear on `job.subtitle_results` with `auto_generated=True`.
@@ -119,9 +164,13 @@ client.jobs # create / get / list / cancel / confirm / watch
119
164
  client.videos # upload helpers, multipart, create_from_url, get / list / update / delete / watch, get_stats / list_top_videos
120
165
  client.presets # create / get / get_by_slug / list / update / duplicate / archive
121
166
  client.origins # create / get / list / update / validate / archive
167
+ client.ingest_rules # create / get / list / update / delete / list_events / test / replay_event
122
168
  client.apps # create / get / list / update / archive / enable_hosting
123
169
  client.api_keys # create / get / list / revoke
124
170
  client.organizations # create / get / list / update / check_slug
171
+ client.billing # list_invoices / retrieve / retrieve_upcoming / retrieve_profile / create_portal_session
172
+ # + retrieve_budget / set_budget / clear_budget
173
+ # + retrieve_outstanding_balance / settle_outstanding_balance
125
174
  client.memberships # list / get / update_role / remove
126
175
  client.users # get_me / get / list / update_me
127
176
  client.health # check
@@ -266,6 +315,120 @@ r2 = {
266
315
 
267
316
  Provide either `account_id` or `endpoint`, never both. `jurisdiction` only applies when `account_id` is set.
268
317
 
318
+ ## Ingest rules
319
+
320
+ An ingest rule is a standing instruction on one readable origin: *when an object
321
+ matching these filters lands, create this job for it*. Your storage provider
322
+ posts its object-created events to the rule's endpoint, and no server of yours
323
+ is in the path. Amazon S3 via SNS, Google Cloud Storage via a Pub/Sub push
324
+ subscription, Supabase Storage via a database webhook, and a generic shape for
325
+ anything else are all recognised from the payload.
326
+
327
+ ```python
328
+ created = client.ingest_rules.create(
329
+ origin_id="ori_a1b2c3d4e5f6",
330
+ name="Watch uploads/",
331
+ filters={
332
+ "prefix": "uploads/",
333
+ "suffixes": [".mp4", ".mov"],
334
+ "min_bytes": 1024, # ignore the zero-byte placeholder some clients write first
335
+ },
336
+ action={
337
+ "outputs": [{"preset": "web_1080p_standard"}],
338
+ "managed": True, # host and deliver the result; use output_origin_id for your own bucket
339
+ "priority": "standard",
340
+ },
341
+ )
342
+
343
+ print("point your bucket notifications at", created.rule.endpoint_url)
344
+ print("secret (shown once):", created.secret)
345
+ ```
346
+
347
+ `created.secret` is the **only** time the inbound secret is readable — store it
348
+ wherever the event sender will read it from. A later `get` returns just
349
+ `secret_prefix` and `secret_hint`. Lost it? `update` with `rotate_secret=True`
350
+ issues a new one, and the previous one keeps working for 24 hours so the sender
351
+ can be changed without dropping an event.
352
+
353
+ Every delivery is recorded, whether or not it became a job:
354
+
355
+ ```python
356
+ for event in client.ingest_rules.list_events(rule_id=created.rule.id).auto_paging_iter():
357
+ print(event.id, event.object_key, event.status, event.reason)
358
+ ```
359
+
360
+ A `skipped` event names why in `reason` — `filter_prefix`, `filter_suffix`,
361
+ `filter_content_type`, `filter_size`, `bucket_mismatch`, `rule_disabled`, or
362
+ `duplicate`. A `failed` one carries the API error code that refused the job,
363
+ such as `limit_exceeded`. Narrow the log to either with the simplified status
364
+ string: `list_events(status="skipped")`.
365
+
366
+ Deduplication is permanent: an object is identified by (rule, bucket, key,
367
+ etag), so re-sending the event or re-uploading the same bytes produces nothing.
368
+ To give an object another pass — one that arrived while the rule was paused, or
369
+ was refused while the account was over its cap — replay it:
370
+
371
+ ```python
372
+ replayed = client.ingest_rules.replay_event("sev_a1b2c3d4e5f6g7")
373
+ print(replayed.id, "is back in", StorageEventStatus.Name(replayed.status))
374
+ ```
375
+
376
+ Only `skipped` and `failed` events can be replayed. When an `update` switches a
377
+ paused rule back on, the response reports how large that backlog is in
378
+ `events_skipped_while_disabled`.
379
+
380
+ Before wiring the provider up, dry-run a key against the rule with
381
+ `client.ingest_rules.test(...)`: it reports whether the filters match and, when
382
+ they do, the exact job request the rule would submit. Nothing is stored.
383
+
384
+ ## Billing
385
+
386
+ Billing settles a whole organization, so `client.billing` needs a dashboard session token for an
387
+ organization **owner** plus the organization it is for — an API key is scoped to one app and is
388
+ rejected outright:
389
+
390
+ ```python
391
+ client = Transcodely("tk_session_token", organization_id="org_f6g7h8i9j0")
392
+
393
+ client.billing.retrieve_upcoming() # what the current period has accrued
394
+ client.billing.list_invoices(limit=12)
395
+ ```
396
+
397
+ Three numbers live here and they are not interchangeable:
398
+
399
+ | Call | What it answers | Enforces? |
400
+ |---|---|---|
401
+ | `billing.retrieve_upcoming()` | What the **current period** has accrued | No |
402
+ | `billing.retrieve_budget()` | Spend against the org's own monthly budget | No — emails only |
403
+ | `billing.retrieve_outstanding_balance()` | What is **unsettled**, incl. earlier periods | Yes, at the hard stop |
404
+ | `apps.set_spend_limit(app_id, eur)` | One app's monthly cap | Yes, at 100% |
405
+
406
+ Budgets notify; limits block:
407
+
408
+ ```python
409
+ client.billing.set_budget(500.0) # emails at 50% / 80% / 100%, restricts nothing
410
+ client.billing.clear_budget() # turns the alerts off
411
+ ```
412
+
413
+ The outstanding balance is the platform's own exposure control. Reminder emails go out at 80%,
414
+ 100%, 125%, 150% and 175% of `threshold_cents` and restrict nothing; only at `hard_stop_cents`
415
+ (twice the threshold) are **new** jobs refused with code `outstanding_balance_exceeded` — queued
416
+ and running work still finishes and videos keep playing. Paying lifts the block on the next
417
+ request:
418
+
419
+ ```python
420
+ balance = client.billing.retrieve_outstanding_balance()
421
+ if balance.blocked and balance.settlement_available:
422
+ result = client.billing.settle_outstanding_balance()
423
+ print(result.settlement.invoice_id, result.balance.outstanding_cents) # → 0
424
+ ```
425
+
426
+ Check `settlement_available` before offering a "pay now" action: settlement raises
427
+ `PreconditionError` with code `settlement_unavailable` where the rail is switched off, and
428
+ `nothing_outstanding` when there is nothing to pay. Job creates refused for money reasons carry
429
+ `billing_past_due` (a statement is unpaid) or `outstanding_balance_exceeded` (too much unbilled
430
+ usage) — neither clears by retrying, so back-off is the wrong response to both.
431
+
269
432
  ## Errors
270
433
 
271
434
  All exceptions inherit from `TranscodelyError`:
@@ -1,6 +1,8 @@
1
1
  # transcodely
2
2
 
3
- Official Python SDK for the [Transcodely](https://transcodely.com) video transcoding API.
3
+ Official Python SDK for [Transcodely](https://www.transcodely.com) — encode video into
4
+ HLS, DASH and MP4 and write it to your own S3, GCS or R2 bucket, with DRM, signed
5
+ playback and deterministic output paths. Or let Transcodely host and deliver it.
4
6
 
5
7
  ```bash
6
8
  pip install transcodely
@@ -38,6 +40,49 @@ with Transcodely(api_key=os.environ["TRANSCODELY_API_KEY"]) as client:
38
40
 
39
41
  The simplified-string form (`"hls"`, `"h264"`, `"1080p"`) is what the API actually emits over the wire — the SDK round-trips it transparently to and from the proto enum integers.
40
42
 
43
+ ## Read the output report
44
+
45
+ Every completed output carries a report of what the produced file actually
46
+ turned out to be — measured from the encoded file rather than copied from the
47
+ request — plus the verdict of comparing those measurements against what was
48
+ asked for.
49
+
50
+ ```python
51
+ from transcodely.types import OutputReport
52
+
53
+ done = client.jobs.get(job.id)
54
+
55
+ for output in done.outputs:
56
+ if not output.HasField("report"):
57
+ continue # not measured — never "nothing wrong"
58
+
59
+ report: OutputReport = output.report
60
+ print(
61
+ output.id,
62
+ report.video.codec,
63
+ f"{report.video.width}x{report.video.height}",
64
+ f"{report.duration_seconds}s",
65
+ )
66
+
67
+ if not report.verdict.matches_request:
68
+ for m in report.verdict.mismatches:
69
+ print(f" {m.field}: asked for {m.expected}, got {m.actual}")
70
+ ```
71
+
72
+ Check `output.HasField("report")` rather than truthiness: an absent report means
73
+ "not measured", which is not the same as a report that found nothing wrong.
74
+ Branch on `m.field` — it comes from a fixed vocabulary (`video.codec`,
75
+ `video.resolution`, `duration_seconds`, …) — rather than on the values beside
76
+ it. For an ABR ladder the facts describe the highest-resolution rendition, the
77
+ same one the verdict judges; per-rendition detail stays in `variant_results`.
78
+
79
+ An output encoded with per-title content-aware analysis also carries
80
+ `report.content_aware`: the VMAF target the search aimed at, the score it reached
81
+ on its samples, and the CRF it chose. It describes the SEARCH, not the delivered
82
+ file — `vmaf_achieved` scores short samples taken before the real encode, which
83
+ is never scored itself. Check `report.HasField("content_aware")`; an ordinary
84
+ output has none.
85
+
41
86
  ## AI captions
42
87
 
43
88
  Add auto-generated captions to any output with a `generate` subtitle track. Leave `language` empty (or set `"auto"`) to auto-detect the spoken language, or pass an ISO 639-2 code to force one. A per-job fee is metered by source minute and surfaced on `job.fees`; produced captions appear on `job.subtitle_results` with `auto_generated=True`.
@@ -82,9 +127,13 @@ client.jobs # create / get / list / cancel / confirm / watch
82
127
  client.videos # upload helpers, multipart, create_from_url, get / list / update / delete / watch, get_stats / list_top_videos
83
128
  client.presets # create / get / get_by_slug / list / update / duplicate / archive
84
129
  client.origins # create / get / list / update / validate / archive
130
+ client.ingest_rules # create / get / list / update / delete / list_events / test / replay_event
85
131
  client.apps # create / get / list / update / archive / enable_hosting
86
132
  client.api_keys # create / get / list / revoke
87
133
  client.organizations # create / get / list / update / check_slug
134
+ client.billing # list_invoices / retrieve / retrieve_upcoming / retrieve_profile / create_portal_session
135
+ # + retrieve_budget / set_budget / clear_budget
136
+ # + retrieve_outstanding_balance / settle_outstanding_balance
88
137
  client.memberships # list / get / update_role / remove
89
138
  client.users # get_me / get / list / update_me
90
139
  client.health # check
@@ -229,6 +278,120 @@ r2 = {
229
278
 
230
279
  Provide either `account_id` or `endpoint`, never both. `jurisdiction` only applies when `account_id` is set.
231
280
 
281
+ ## Ingest rules
282
+
283
+ An ingest rule is a standing instruction on one readable origin: *when an object
284
+ matching these filters lands, create this job for it*. Your storage provider
285
+ posts its object-created events to the rule's endpoint, and no server of yours
286
+ is in the path. Amazon S3 via SNS, Google Cloud Storage via a Pub/Sub push
287
+ subscription, Supabase Storage via a database webhook, and a generic shape for
288
+ anything else are all recognised from the payload.
289
+
290
+ ```python
291
+ created = client.ingest_rules.create(
292
+ origin_id="ori_a1b2c3d4e5f6",
293
+ name="Watch uploads/",
294
+ filters={
295
+ "prefix": "uploads/",
296
+ "suffixes": [".mp4", ".mov"],
297
+ "min_bytes": 1024, # ignore the zero-byte placeholder some clients write first
298
+ },
299
+ action={
300
+ "outputs": [{"preset": "web_1080p_standard"}],
301
+ "managed": True, # host and deliver the result; use output_origin_id for your own bucket
302
+ "priority": "standard",
303
+ },
304
+ )
305
+
306
+ print("point your bucket notifications at", created.rule.endpoint_url)
307
+ print("secret (shown once):", created.secret)
308
+ ```
309
+
310
+ `created.secret` is the **only** time the inbound secret is readable — store it
311
+ wherever the event sender will read it from. A later `get` returns just
312
+ `secret_prefix` and `secret_hint`. Lost it? `update` with `rotate_secret=True`
313
+ issues a new one, and the previous one keeps working for 24 hours so the sender
314
+ can be changed without dropping an event.
315
+
316
+ Every delivery is recorded, whether or not it became a job:
317
+
318
+ ```python
319
+ for event in client.ingest_rules.list_events(rule_id=created.rule.id).auto_paging_iter():
320
+ print(event.id, event.object_key, event.status, event.reason)
321
+ ```
322
+
323
+ A `skipped` event names why in `reason` — `filter_prefix`, `filter_suffix`,
324
+ `filter_content_type`, `filter_size`, `bucket_mismatch`, `rule_disabled`, or
325
+ `duplicate`. A `failed` one carries the API error code that refused the job,
326
+ such as `limit_exceeded`. Narrow the log to either with the simplified status
327
+ string: `list_events(status="skipped")`.
328
+
329
+ Deduplication is permanent: an object is identified by (rule, bucket, key,
330
+ etag), so re-sending the event or re-uploading the same bytes produces nothing.
331
+ To give an object another pass — one that arrived while the rule was paused, or
332
+ was refused while the account was over its cap — replay it:
333
+
334
+ ```python
335
+ replayed = client.ingest_rules.replay_event("sev_a1b2c3d4e5f6g7")
336
+ print(replayed.id, "is back in", StorageEventStatus.Name(replayed.status))
337
+ ```
338
+
339
+ Only `skipped` and `failed` events can be replayed. When an `update` switches a
340
+ paused rule back on, the response reports how large that backlog is in
341
+ `events_skipped_while_disabled`.
342
+
343
+ Before wiring the provider up, dry-run a key against the rule with
344
+ `client.ingest_rules.test(...)`: it reports whether the filters match and, when
345
+ they do, the exact job request the rule would submit. Nothing is stored.
346
+
347
+ ## Billing
348
+
349
+ Billing settles a whole organization, so `client.billing` needs a dashboard session token for an
350
+ organization **owner** plus the organization it is for — an API key is scoped to one app and is
351
+ rejected outright:
352
+
353
+ ```python
354
+ client = Transcodely("tk_session_token", organization_id="org_f6g7h8i9j0")
355
+
356
+ client.billing.retrieve_upcoming() # what the current period has accrued
357
+ client.billing.list_invoices(limit=12)
358
+ ```
359
+
360
+ Three numbers live here and they are not interchangeable:
361
+
362
+ | Call | What it answers | Enforces? |
363
+ |---|---|---|
364
+ | `billing.retrieve_upcoming()` | What the **current period** has accrued | No |
365
+ | `billing.retrieve_budget()` | Spend against the org's own monthly budget | No — emails only |
366
+ | `billing.retrieve_outstanding_balance()` | What is **unsettled**, incl. earlier periods | Yes, at the hard stop |
367
+ | `apps.set_spend_limit(app_id, eur)` | One app's monthly cap | Yes, at 100% |
368
+
369
+ Budgets notify; limits block:
370
+
371
+ ```python
372
+ client.billing.set_budget(500.0) # emails at 50% / 80% / 100%, restricts nothing
373
+ client.billing.clear_budget() # turns the alerts off
374
+ ```
375
+
376
+ The outstanding balance is the platform's own exposure control. Reminder emails go out at 80%,
377
+ 100%, 125%, 150% and 175% of `threshold_cents` and restrict nothing; only at `hard_stop_cents`
378
+ (twice the threshold) are **new** jobs refused with code `outstanding_balance_exceeded` — queued
379
+ and running work still finishes and videos keep playing. Paying lifts the block on the next
380
+ request:
381
+
382
+ ```python
383
+ balance = client.billing.retrieve_outstanding_balance()
384
+ if balance.blocked and balance.settlement_available:
385
+ result = client.billing.settle_outstanding_balance()
386
+ print(result.settlement.invoice_id, result.balance.outstanding_cents) # → 0
387
+ ```
388
+
389
+ Check `settlement_available` before offering a "pay now" action: settlement raises
390
+ `PreconditionError` with code `settlement_unavailable` where the rail is switched off, and
391
+ `nothing_outstanding` when there is nothing to pay. Job creates refused for money reasons carry
392
+ `billing_past_due` (a statement is unpaid) or `outstanding_balance_exceeded` (too much unbilled
393
+ usage) — neither clears by retrying, so back-off is the wrong response to both.
394
+
232
395
  ## Errors
233
396
 
234
397
  All exceptions inherit from `TranscodelyError`:
@@ -4,14 +4,16 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "transcodely"
7
- version = "0.3.7"
8
- description = "Official Python SDK for the Transcodely video transcoding API"
7
+ version = "0.3.9"
8
+ description = "Official Python SDK for Transcodely — encode video to HLS, DASH and MP4 into your own S3, GCS or R2 bucket, with DRM and signed playback."
9
9
  readme = "README.md"
10
10
  license = "MIT"
11
11
  license-files = ["LICENSE"]
12
12
  authors = [{ name = "Transcodely" }]
13
13
  requires-python = ">=3.10"
14
- keywords = ["transcodely", "video", "transcoding", "encoding", "hls", "dash", "streaming", "connect-rpc"]
14
+ keywords = ["transcodely", "video", "transcoding", "encoding", "hls", "dash", "cmaf",
15
+ "drm", "widevine", "fairplay", "abr", "ffmpeg", "s3", "r2", "signed-urls",
16
+ "video-hosting", "streaming", "connect-rpc"]
15
17
  classifiers = [
16
18
  "Development Status :: 3 - Alpha",
17
19
  "Intended Audience :: Developers",
@@ -42,8 +44,8 @@ dev = [
42
44
  ]
43
45
 
44
46
  [project.urls]
45
- Homepage = "https://transcodely.com"
46
- Documentation = "https://transcodely.com/docs"
47
+ Homepage = "https://www.transcodely.com"
48
+ Documentation = "https://www.transcodely.com/docs"
47
49
  Repository = "https://github.com/transcodely/transcodely-python"
48
50
  Issues = "https://github.com/transcodely/transcodely-python/issues"
49
51
 
@@ -67,6 +67,7 @@ from .types import (
67
67
  DRMConfig,
68
68
  HDRConfig,
69
69
  HLSConfig,
70
+ IngestRule,
70
71
  Invoice,
71
72
  InvoiceLineItem,
72
73
  InvoiceLineType,
@@ -85,6 +86,9 @@ from .types import (
85
86
  Preset,
86
87
  PresetVariant,
87
88
  Resolution,
89
+ StorageEvent,
90
+ StorageEventSource,
91
+ StorageEventStatus,
88
92
  StreamingConfig,
89
93
  SubtitleResult,
90
94
  SubtitleTrack,
@@ -149,6 +153,7 @@ __all__ = [
149
153
  "FieldViolation",
150
154
  "HDRConfig",
151
155
  "HLSConfig",
156
+ "IngestRule",
152
157
  "InvalidRequestError",
153
158
  "Invoice",
154
159
  "InvoiceLineItem",
@@ -174,6 +179,9 @@ __all__ = [
174
179
  "PresetVariant",
175
180
  "RateLimitError",
176
181
  "Resolution",
182
+ "StorageEvent",
183
+ "StorageEventSource",
184
+ "StorageEventStatus",
177
185
  "StreamingConfig",
178
186
  "SubtitleResult",
179
187
  "SubtitleTrack",
@@ -13,6 +13,7 @@ from .resources.apps import Apps
13
13
  from .resources.billing import Billing
14
14
  from .resources.events import Events
15
15
  from .resources.health import Health
16
+ from .resources.ingest_rules import IngestRules
16
17
  from .resources.jobs import Jobs
17
18
  from .resources.memberships import Memberships
18
19
  from .resources.organizations import Organizations
@@ -63,6 +64,7 @@ class Transcodely:
63
64
  self._videos: Videos | None = None
64
65
  self._presets: Presets | None = None
65
66
  self._origins: Origins | None = None
67
+ self._ingest_rules: IngestRules | None = None
66
68
  self._apps: Apps | None = None
67
69
  self._api_keys: ApiKeys | None = None
68
70
  self._organizations: Organizations | None = None
@@ -111,6 +113,13 @@ class Transcodely:
111
113
  self._origins = Origins(self._transport)
112
114
  return self._origins
113
115
 
116
+ @property
117
+ def ingest_rules(self) -> IngestRules:
118
+ """Ingest rules: watch a bucket, transcode what lands in it."""
119
+ if self._ingest_rules is None:
120
+ self._ingest_rules = IngestRules(self._transport)
121
+ return self._ingest_rules
122
+
114
123
  @property
115
124
  def apps(self) -> Apps:
116
125
  if self._apps is None: