transcodely 0.3.8__tar.gz → 0.3.10__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 (95) hide show
  1. {transcodely-0.3.8 → transcodely-0.3.10}/PKG-INFO +130 -6
  2. {transcodely-0.3.8 → transcodely-0.3.10}/README.md +125 -1
  3. {transcodely-0.3.8 → transcodely-0.3.10}/pyproject.toml +7 -5
  4. {transcodely-0.3.8 → transcodely-0.3.10}/src/transcodely/__init__.py +8 -0
  5. {transcodely-0.3.8 → transcodely-0.3.10}/src/transcodely/client.py +9 -0
  6. {transcodely-0.3.8 → transcodely-0.3.10}/src/transcodely/resources/events.py +12 -2
  7. transcodely-0.3.10/src/transcodely/resources/ingest_rules.py +183 -0
  8. {transcodely-0.3.8 → transcodely-0.3.10}/src/transcodely/resources/webhook_endpoints.py +12 -2
  9. {transcodely-0.3.8 → transcodely-0.3.10}/src/transcodely/types.py +80 -5
  10. {transcodely-0.3.8 → transcodely-0.3.10}/src/transcodely/v1/api_key_pb2.py +19 -19
  11. {transcodely-0.3.8 → transcodely-0.3.10}/src/transcodely/v1/content_aware_pb2.py +2 -2
  12. transcodely-0.3.10/src/transcodely/v1/ingest_rule_pb2.py +157 -0
  13. transcodely-0.3.10/src/transcodely/v1/ingest_rule_pb2.pyi +305 -0
  14. {transcodely-0.3.8 → transcodely-0.3.10}/src/transcodely/v1/job_pb2.py +74 -58
  15. {transcodely-0.3.8 → transcodely-0.3.10}/src/transcodely/v1/job_pb2.pyi +118 -2
  16. {transcodely-0.3.8 → transcodely-0.3.10}/src/transcodely/v1/video_pb2.py +4 -4
  17. {transcodely-0.3.8 → transcodely-0.3.10}/src/transcodely/v1/webhook_pb2.py +4 -4
  18. {transcodely-0.3.8 → transcodely-0.3.10}/src/transcodely/version.py +1 -1
  19. {transcodely-0.3.8 → transcodely-0.3.10}/.gitignore +0 -0
  20. {transcodely-0.3.8 → transcodely-0.3.10}/LICENSE +0 -0
  21. {transcodely-0.3.8 → transcodely-0.3.10}/src/buf/__init__.py +0 -0
  22. {transcodely-0.3.8 → transcodely-0.3.10}/src/buf/validate/__init__.py +0 -0
  23. {transcodely-0.3.8 → transcodely-0.3.10}/src/buf/validate/validate_pb2.py +0 -0
  24. {transcodely-0.3.8 → transcodely-0.3.10}/src/buf/validate/validate_pb2.pyi +0 -0
  25. {transcodely-0.3.8 → transcodely-0.3.10}/src/transcodely/_codec/__init__.py +0 -0
  26. {transcodely-0.3.8 → transcodely-0.3.10}/src/transcodely/_codec/json_codec.py +0 -0
  27. {transcodely-0.3.8 → transcodely-0.3.10}/src/transcodely/_transport/__init__.py +0 -0
  28. {transcodely-0.3.8 → transcodely-0.3.10}/src/transcodely/_transport/error_mapping.py +0 -0
  29. {transcodely-0.3.8 → transcodely-0.3.10}/src/transcodely/_transport/headers.py +0 -0
  30. {transcodely-0.3.8 → transcodely-0.3.10}/src/transcodely/_transport/transport.py +0 -0
  31. {transcodely-0.3.8 → transcodely-0.3.10}/src/transcodely/errors.py +0 -0
  32. {transcodely-0.3.8 → transcodely-0.3.10}/src/transcodely/pagination.py +0 -0
  33. {transcodely-0.3.8 → transcodely-0.3.10}/src/transcodely/py.typed +0 -0
  34. {transcodely-0.3.8 → transcodely-0.3.10}/src/transcodely/resources/__init__.py +0 -0
  35. {transcodely-0.3.8 → transcodely-0.3.10}/src/transcodely/resources/_helpers.py +0 -0
  36. {transcodely-0.3.8 → transcodely-0.3.10}/src/transcodely/resources/api_keys.py +0 -0
  37. {transcodely-0.3.8 → transcodely-0.3.10}/src/transcodely/resources/apps.py +0 -0
  38. {transcodely-0.3.8 → transcodely-0.3.10}/src/transcodely/resources/billing.py +0 -0
  39. {transcodely-0.3.8 → transcodely-0.3.10}/src/transcodely/resources/health.py +0 -0
  40. {transcodely-0.3.8 → transcodely-0.3.10}/src/transcodely/resources/jobs.py +0 -0
  41. {transcodely-0.3.8 → transcodely-0.3.10}/src/transcodely/resources/memberships.py +0 -0
  42. {transcodely-0.3.8 → transcodely-0.3.10}/src/transcodely/resources/organizations.py +0 -0
  43. {transcodely-0.3.8 → transcodely-0.3.10}/src/transcodely/resources/origins.py +0 -0
  44. {transcodely-0.3.8 → transcodely-0.3.10}/src/transcodely/resources/presets.py +0 -0
  45. {transcodely-0.3.8 → transcodely-0.3.10}/src/transcodely/resources/users.py +0 -0
  46. {transcodely-0.3.8 → transcodely-0.3.10}/src/transcodely/resources/videos.py +0 -0
  47. {transcodely-0.3.8 → transcodely-0.3.10}/src/transcodely/streaming.py +0 -0
  48. {transcodely-0.3.8 → transcodely-0.3.10}/src/transcodely/v1/api_key_pb2.pyi +0 -0
  49. {transcodely-0.3.8 → transcodely-0.3.10}/src/transcodely/v1/app_pb2.py +0 -0
  50. {transcodely-0.3.8 → transcodely-0.3.10}/src/transcodely/v1/app_pb2.pyi +0 -0
  51. {transcodely-0.3.8 → transcodely-0.3.10}/src/transcodely/v1/billing_pb2.py +0 -0
  52. {transcodely-0.3.8 → transcodely-0.3.10}/src/transcodely/v1/billing_pb2.pyi +0 -0
  53. {transcodely-0.3.8 → transcodely-0.3.10}/src/transcodely/v1/codec_av1_pb2.py +0 -0
  54. {transcodely-0.3.8 → transcodely-0.3.10}/src/transcodely/v1/codec_av1_pb2.pyi +0 -0
  55. {transcodely-0.3.8 → transcodely-0.3.10}/src/transcodely/v1/codec_h264_pb2.py +0 -0
  56. {transcodely-0.3.8 → transcodely-0.3.10}/src/transcodely/v1/codec_h264_pb2.pyi +0 -0
  57. {transcodely-0.3.8 → transcodely-0.3.10}/src/transcodely/v1/codec_h265_pb2.py +0 -0
  58. {transcodely-0.3.8 → transcodely-0.3.10}/src/transcodely/v1/codec_h265_pb2.pyi +0 -0
  59. {transcodely-0.3.8 → transcodely-0.3.10}/src/transcodely/v1/codec_vp9_pb2.py +0 -0
  60. {transcodely-0.3.8 → transcodely-0.3.10}/src/transcodely/v1/codec_vp9_pb2.pyi +0 -0
  61. {transcodely-0.3.8 → transcodely-0.3.10}/src/transcodely/v1/common_pb2.py +0 -0
  62. {transcodely-0.3.8 → transcodely-0.3.10}/src/transcodely/v1/common_pb2.pyi +0 -0
  63. {transcodely-0.3.8 → transcodely-0.3.10}/src/transcodely/v1/content_aware_pb2.pyi +0 -0
  64. {transcodely-0.3.8 → transcodely-0.3.10}/src/transcodely/v1/drm_pb2.py +0 -0
  65. {transcodely-0.3.8 → transcodely-0.3.10}/src/transcodely/v1/drm_pb2.pyi +0 -0
  66. {transcodely-0.3.8 → transcodely-0.3.10}/src/transcodely/v1/hdr_pb2.py +0 -0
  67. {transcodely-0.3.8 → transcodely-0.3.10}/src/transcodely/v1/hdr_pb2.pyi +0 -0
  68. {transcodely-0.3.8 → transcodely-0.3.10}/src/transcodely/v1/health_pb2.py +0 -0
  69. {transcodely-0.3.8 → transcodely-0.3.10}/src/transcodely/v1/health_pb2.pyi +0 -0
  70. {transcodely-0.3.8 → transcodely-0.3.10}/src/transcodely/v1/media_pb2.py +0 -0
  71. {transcodely-0.3.8 → transcodely-0.3.10}/src/transcodely/v1/media_pb2.pyi +0 -0
  72. {transcodely-0.3.8 → transcodely-0.3.10}/src/transcodely/v1/membership_pb2.py +0 -0
  73. {transcodely-0.3.8 → transcodely-0.3.10}/src/transcodely/v1/membership_pb2.pyi +0 -0
  74. {transcodely-0.3.8 → transcodely-0.3.10}/src/transcodely/v1/organization_pb2.py +0 -0
  75. {transcodely-0.3.8 → transcodely-0.3.10}/src/transcodely/v1/organization_pb2.pyi +0 -0
  76. {transcodely-0.3.8 → transcodely-0.3.10}/src/transcodely/v1/origin_pb2.py +0 -0
  77. {transcodely-0.3.8 → transcodely-0.3.10}/src/transcodely/v1/origin_pb2.pyi +0 -0
  78. {transcodely-0.3.8 → transcodely-0.3.10}/src/transcodely/v1/preset_pb2.py +0 -0
  79. {transcodely-0.3.8 → transcodely-0.3.10}/src/transcodely/v1/preset_pb2.pyi +0 -0
  80. {transcodely-0.3.8 → transcodely-0.3.10}/src/transcodely/v1/streaming_pb2.py +0 -0
  81. {transcodely-0.3.8 → transcodely-0.3.10}/src/transcodely/v1/streaming_pb2.pyi +0 -0
  82. {transcodely-0.3.8 → transcodely-0.3.10}/src/transcodely/v1/subtitles_pb2.py +0 -0
  83. {transcodely-0.3.8 → transcodely-0.3.10}/src/transcodely/v1/subtitles_pb2.pyi +0 -0
  84. {transcodely-0.3.8 → transcodely-0.3.10}/src/transcodely/v1/thumbnails_pb2.py +0 -0
  85. {transcodely-0.3.8 → transcodely-0.3.10}/src/transcodely/v1/thumbnails_pb2.pyi +0 -0
  86. {transcodely-0.3.8 → transcodely-0.3.10}/src/transcodely/v1/user_pb2.py +0 -0
  87. {transcodely-0.3.8 → transcodely-0.3.10}/src/transcodely/v1/user_pb2.pyi +0 -0
  88. {transcodely-0.3.8 → transcodely-0.3.10}/src/transcodely/v1/video_pb2.pyi +0 -0
  89. {transcodely-0.3.8 → transcodely-0.3.10}/src/transcodely/v1/watermark_pb2.py +0 -0
  90. {transcodely-0.3.8 → transcodely-0.3.10}/src/transcodely/v1/watermark_pb2.pyi +0 -0
  91. {transcodely-0.3.8 → transcodely-0.3.10}/src/transcodely/v1/webhook_pb2.pyi +0 -0
  92. {transcodely-0.3.8 → transcodely-0.3.10}/src/transcodely/webhooks/__init__.py +0 -0
  93. {transcodely-0.3.8 → transcodely-0.3.10}/src/transcodely/webhooks/construct_event.py +0 -0
  94. {transcodely-0.3.8 → transcodely-0.3.10}/src/transcodely/webhooks/signature.py +0 -0
  95. {transcodely-0.3.8 → transcodely-0.3.10}/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.8
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.10
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,61 @@ 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, the CRF it chose, and — since API 5.23.0 — the whole curve the
119
+ search measured to get there: `seed_crf` (what the rung would have used without
120
+ the search), `met_target` (whether any probe reached the VMAF target), and
121
+ `probes` (every sample point, in order, each with `crf`, `vmaf`, and
122
+ `bitrate_kbps`). It describes the SEARCH, not the delivered file — every number
123
+ is measured on short samples cut from the source, and the delivered file is
124
+ never itself scored. Check `report.HasField("content_aware")`; an ordinary
125
+ output has none. `probes` and `met_target` are empty/absent for workers older
126
+ than 1.29.0, which ran the search but didn't report its curve.
127
+
128
+ A probe's `bitrate_kbps` is derived from a sample encoded **video-only** — the
129
+ search's cuts drop audio, subtitles and data — so it's the sample's video
130
+ bitrate, not a muxed file's rate. The only defensible saving is the ratio
131
+ between the `seed_crf` probe's bitrate and the `crf_chosen` probe's: same cut,
132
+ same settings, only the CRF differs. Comparing a probe's bitrate against the
133
+ delivered output's `average_bitrate_kbps` compares two different things.
134
+
78
135
  ## AI captions
79
136
 
80
137
  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,6 +176,7 @@ client.jobs # create / get / list / cancel / confirm / watch
119
176
  client.videos # upload helpers, multipart, create_from_url, get / list / update / delete / watch, get_stats / list_top_videos
120
177
  client.presets # create / get / get_by_slug / list / update / duplicate / archive
121
178
  client.origins # create / get / list / update / validate / archive
179
+ client.ingest_rules # create / get / list / update / delete / list_events / test / replay_event
122
180
  client.apps # create / get / list / update / archive / enable_hosting
123
181
  client.api_keys # create / get / list / revoke
124
182
  client.organizations # create / get / list / update / check_slug
@@ -269,6 +327,72 @@ r2 = {
269
327
 
270
328
  Provide either `account_id` or `endpoint`, never both. `jurisdiction` only applies when `account_id` is set.
271
329
 
330
+ ## Ingest rules
331
+
332
+ An ingest rule is a standing instruction on one readable origin: *when an object
333
+ matching these filters lands, create this job for it*. Your storage provider
334
+ posts its object-created events to the rule's endpoint, and no server of yours
335
+ is in the path. Amazon S3 via SNS, Google Cloud Storage via a Pub/Sub push
336
+ subscription, Supabase Storage via a database webhook, and a generic shape for
337
+ anything else are all recognised from the payload.
338
+
339
+ ```python
340
+ created = client.ingest_rules.create(
341
+ origin_id="ori_a1b2c3d4e5f6",
342
+ name="Watch uploads/",
343
+ filters={
344
+ "prefix": "uploads/",
345
+ "suffixes": [".mp4", ".mov"],
346
+ "min_bytes": 1024, # ignore the zero-byte placeholder some clients write first
347
+ },
348
+ action={
349
+ "outputs": [{"preset": "web_1080p_standard"}],
350
+ "managed": True, # host and deliver the result; use output_origin_id for your own bucket
351
+ "priority": "standard",
352
+ },
353
+ )
354
+
355
+ print("point your bucket notifications at", created.rule.endpoint_url)
356
+ print("secret (shown once):", created.secret)
357
+ ```
358
+
359
+ `created.secret` is the **only** time the inbound secret is readable — store it
360
+ wherever the event sender will read it from. A later `get` returns just
361
+ `secret_prefix` and `secret_hint`. Lost it? `update` with `rotate_secret=True`
362
+ issues a new one, and the previous one keeps working for 24 hours so the sender
363
+ can be changed without dropping an event.
364
+
365
+ Every delivery is recorded, whether or not it became a job:
366
+
367
+ ```python
368
+ for event in client.ingest_rules.list_events(rule_id=created.rule.id).auto_paging_iter():
369
+ print(event.id, event.object_key, event.status, event.reason)
370
+ ```
371
+
372
+ A `skipped` event names why in `reason` — `filter_prefix`, `filter_suffix`,
373
+ `filter_content_type`, `filter_size`, `bucket_mismatch`, `rule_disabled`, or
374
+ `duplicate`. A `failed` one carries the API error code that refused the job,
375
+ such as `limit_exceeded`. Narrow the log to either with the simplified status
376
+ string: `list_events(status="skipped")`.
377
+
378
+ Deduplication is permanent: an object is identified by (rule, bucket, key,
379
+ etag), so re-sending the event or re-uploading the same bytes produces nothing.
380
+ To give an object another pass — one that arrived while the rule was paused, or
381
+ was refused while the account was over its cap — replay it:
382
+
383
+ ```python
384
+ replayed = client.ingest_rules.replay_event("sev_a1b2c3d4e5f6g7")
385
+ print(replayed.id, "is back in", StorageEventStatus.Name(replayed.status))
386
+ ```
387
+
388
+ Only `skipped` and `failed` events can be replayed. When an `update` switches a
389
+ paused rule back on, the response reports how large that backlog is in
390
+ `events_skipped_while_disabled`.
391
+
392
+ Before wiring the provider up, dry-run a key against the rule with
393
+ `client.ingest_rules.test(...)`: it reports whether the filters match and, when
394
+ they do, the exact job request the rule would submit. Nothing is stored.
395
+
272
396
  ## Billing
273
397
 
274
398
  Billing settles a whole organization, so `client.billing` needs a dashboard session token for an
@@ -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,61 @@ 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, the CRF it chose, and — since API 5.23.0 — the whole curve the
82
+ search measured to get there: `seed_crf` (what the rung would have used without
83
+ the search), `met_target` (whether any probe reached the VMAF target), and
84
+ `probes` (every sample point, in order, each with `crf`, `vmaf`, and
85
+ `bitrate_kbps`). It describes the SEARCH, not the delivered file — every number
86
+ is measured on short samples cut from the source, and the delivered file is
87
+ never itself scored. Check `report.HasField("content_aware")`; an ordinary
88
+ output has none. `probes` and `met_target` are empty/absent for workers older
89
+ than 1.29.0, which ran the search but didn't report its curve.
90
+
91
+ A probe's `bitrate_kbps` is derived from a sample encoded **video-only** — the
92
+ search's cuts drop audio, subtitles and data — so it's the sample's video
93
+ bitrate, not a muxed file's rate. The only defensible saving is the ratio
94
+ between the `seed_crf` probe's bitrate and the `crf_chosen` probe's: same cut,
95
+ same settings, only the CRF differs. Comparing a probe's bitrate against the
96
+ delivered output's `average_bitrate_kbps` compares two different things.
97
+
41
98
  ## AI captions
42
99
 
43
100
  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,6 +139,7 @@ client.jobs # create / get / list / cancel / confirm / watch
82
139
  client.videos # upload helpers, multipart, create_from_url, get / list / update / delete / watch, get_stats / list_top_videos
83
140
  client.presets # create / get / get_by_slug / list / update / duplicate / archive
84
141
  client.origins # create / get / list / update / validate / archive
142
+ client.ingest_rules # create / get / list / update / delete / list_events / test / replay_event
85
143
  client.apps # create / get / list / update / archive / enable_hosting
86
144
  client.api_keys # create / get / list / revoke
87
145
  client.organizations # create / get / list / update / check_slug
@@ -232,6 +290,72 @@ r2 = {
232
290
 
233
291
  Provide either `account_id` or `endpoint`, never both. `jurisdiction` only applies when `account_id` is set.
234
292
 
293
+ ## Ingest rules
294
+
295
+ An ingest rule is a standing instruction on one readable origin: *when an object
296
+ matching these filters lands, create this job for it*. Your storage provider
297
+ posts its object-created events to the rule's endpoint, and no server of yours
298
+ is in the path. Amazon S3 via SNS, Google Cloud Storage via a Pub/Sub push
299
+ subscription, Supabase Storage via a database webhook, and a generic shape for
300
+ anything else are all recognised from the payload.
301
+
302
+ ```python
303
+ created = client.ingest_rules.create(
304
+ origin_id="ori_a1b2c3d4e5f6",
305
+ name="Watch uploads/",
306
+ filters={
307
+ "prefix": "uploads/",
308
+ "suffixes": [".mp4", ".mov"],
309
+ "min_bytes": 1024, # ignore the zero-byte placeholder some clients write first
310
+ },
311
+ action={
312
+ "outputs": [{"preset": "web_1080p_standard"}],
313
+ "managed": True, # host and deliver the result; use output_origin_id for your own bucket
314
+ "priority": "standard",
315
+ },
316
+ )
317
+
318
+ print("point your bucket notifications at", created.rule.endpoint_url)
319
+ print("secret (shown once):", created.secret)
320
+ ```
321
+
322
+ `created.secret` is the **only** time the inbound secret is readable — store it
323
+ wherever the event sender will read it from. A later `get` returns just
324
+ `secret_prefix` and `secret_hint`. Lost it? `update` with `rotate_secret=True`
325
+ issues a new one, and the previous one keeps working for 24 hours so the sender
326
+ can be changed without dropping an event.
327
+
328
+ Every delivery is recorded, whether or not it became a job:
329
+
330
+ ```python
331
+ for event in client.ingest_rules.list_events(rule_id=created.rule.id).auto_paging_iter():
332
+ print(event.id, event.object_key, event.status, event.reason)
333
+ ```
334
+
335
+ A `skipped` event names why in `reason` — `filter_prefix`, `filter_suffix`,
336
+ `filter_content_type`, `filter_size`, `bucket_mismatch`, `rule_disabled`, or
337
+ `duplicate`. A `failed` one carries the API error code that refused the job,
338
+ such as `limit_exceeded`. Narrow the log to either with the simplified status
339
+ string: `list_events(status="skipped")`.
340
+
341
+ Deduplication is permanent: an object is identified by (rule, bucket, key,
342
+ etag), so re-sending the event or re-uploading the same bytes produces nothing.
343
+ To give an object another pass — one that arrived while the rule was paused, or
344
+ was refused while the account was over its cap — replay it:
345
+
346
+ ```python
347
+ replayed = client.ingest_rules.replay_event("sev_a1b2c3d4e5f6g7")
348
+ print(replayed.id, "is back in", StorageEventStatus.Name(replayed.status))
349
+ ```
350
+
351
+ Only `skipped` and `failed` events can be replayed. When an `update` switches a
352
+ paused rule back on, the response reports how large that backlog is in
353
+ `events_skipped_while_disabled`.
354
+
355
+ Before wiring the provider up, dry-run a key against the rule with
356
+ `client.ingest_rules.test(...)`: it reports whether the filters match and, when
357
+ they do, the exact job request the rule would submit. Nothing is stored.
358
+
235
359
  ## Billing
236
360
 
237
361
  Billing settles a whole organization, so `client.billing` needs a dashboard session token for an
@@ -4,14 +4,16 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "transcodely"
7
- version = "0.3.8"
8
- description = "Official Python SDK for the Transcodely video transcoding API"
7
+ version = "0.3.10"
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:
@@ -52,15 +52,25 @@ class Events:
52
52
  def list(
53
53
  self,
54
54
  *,
55
- app_id: str,
55
+ app_id: str | None = None,
56
56
  type: str | None = None,
57
57
  created_after: datetime | None = None,
58
58
  created_before: datetime | None = None,
59
59
  limit: int | None = None,
60
60
  opts: CallOptions | None = None,
61
61
  ) -> Page[Event]:
62
+ """List events, newest first.
63
+
64
+ ``app_id`` is an optional filter. An API-key caller may omit it and get
65
+ their key's own app; passing a different app's ID is refused with
66
+ ``permission_denied``. A portal user who omits it gets the events of
67
+ every app in the organization.
68
+ """
69
+
62
70
  def fetch(cursor: str | None) -> PageContents[Event]:
63
- req = webhook_pb2.ListEventsRequest(app_id=app_id)
71
+ req = webhook_pb2.ListEventsRequest()
72
+ if app_id is not None:
73
+ req.app_id = app_id
64
74
  if type is not None:
65
75
  req.type = type
66
76
  if created_after is not None:
@@ -0,0 +1,183 @@
1
+ """Ingest rule resource — watch a bucket, transcode what lands in it.
2
+
3
+ An ingest rule is a standing instruction on one readable storage origin: when
4
+ an object matching its filters appears, create the job its action describes.
5
+ Your storage provider posts object-created events to the rule's
6
+ ``endpoint_url``, authenticated with the rule's secret, so no server of yours is
7
+ in the path. Amazon S3 via SNS, Google Cloud Storage via a Pub/Sub push
8
+ subscription, Supabase Storage via a database webhook, and a generic shape for
9
+ anything else are all recognised from the payload.
10
+
11
+ Duplicate deliveries are absorbed: an object is identified by
12
+ (rule, bucket, key, etag) and produces exactly one job.
13
+
14
+ The inbound endpoint itself is not part of this SDK — your storage provider
15
+ calls it, not you.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ from typing import Any, cast
21
+
22
+ from .._transport.transport import CallOptions, Transport
23
+ from ..pagination import Page, PageContents
24
+ from ..v1 import ingest_rule_pb2
25
+ from ._helpers import assign_pagination, fill_from_dict, resolve_enum
26
+
27
+ _SERVICE = "transcodely.v1.IngestRuleService"
28
+
29
+
30
+ class IngestRules:
31
+ def __init__(self, transport: Transport) -> None:
32
+ self._t = transport
33
+
34
+ def create(self, **kwargs: Any) -> ingest_rule_pb2.CreateIngestRuleResponse:
35
+ """Create a rule on a readable origin.
36
+
37
+ The whole response is returned because ``secret`` rides on it: the
38
+ inbound secret is shown once, here, and cannot be read back. Store it
39
+ wherever the event sender will read it from.
40
+ """
41
+ req = fill_from_dict(ingest_rule_pb2.CreateIngestRuleRequest(), kwargs)
42
+ return self._t.unary(_SERVICE, "Create", req, ingest_rule_pb2.CreateIngestRuleResponse())
43
+
44
+ def get(self, id: str, opts: CallOptions | None = None) -> ingest_rule_pb2.IngestRule:
45
+ """Fetch a rule by ID (``ing_*``).
46
+
47
+ The secret is never returned again — the rule carries only
48
+ ``secret_prefix`` and ``secret_hint``.
49
+ """
50
+ req = ingest_rule_pb2.GetIngestRuleRequest(id=id)
51
+ return self._t.unary(
52
+ _SERVICE, "Get", req, ingest_rule_pb2.GetIngestRuleResponse(), opts
53
+ ).rule
54
+
55
+ def list(
56
+ self,
57
+ *,
58
+ origin_id: str | None = None,
59
+ enabled: bool | None = None,
60
+ app_id: str | None = None,
61
+ limit: int | None = None,
62
+ opts: CallOptions | None = None,
63
+ ) -> Page[ingest_rule_pb2.IngestRule]:
64
+ """Rules in scope, newest first."""
65
+
66
+ def fetch(cursor: str | None) -> PageContents[ingest_rule_pb2.IngestRule]:
67
+ req = ingest_rule_pb2.ListIngestRulesRequest()
68
+ if origin_id is not None:
69
+ req.origin_id = origin_id
70
+ if enabled is not None:
71
+ req.enabled = enabled
72
+ if app_id is not None:
73
+ req.app_id = app_id
74
+ assign_pagination(req.pagination, limit=limit, cursor=cursor)
75
+ res = self._t.unary(
76
+ _SERVICE, "List", req, ingest_rule_pb2.ListIngestRulesResponse(), opts
77
+ )
78
+ return PageContents(
79
+ items=list(res.rules), next_cursor=res.pagination.next_cursor or None
80
+ )
81
+
82
+ return Page(fetch)
83
+
84
+ def update(self, **kwargs: Any) -> ingest_rule_pb2.UpdateIngestRuleResponse:
85
+ """Update name, enabled state, filters or action, optionally rotating the secret.
86
+
87
+ The update MERGES: it applies only what it carries, down to the
88
+ individual filters and the individual parts of the action. Narrowing a
89
+ rule to a new prefix is ``filters={"prefix": "raw/"}`` and nothing else
90
+ — the suffix, content-type and size filters are untouched.
91
+
92
+ Removing something rather than changing it takes the two clear flags.
93
+ ``clear_filters=True`` empties the filter set before ``filters`` is
94
+ applied, so on its own it widens the rule to everything in the bucket.
95
+ ``clear_action=True`` replaces the action outright, and ``action`` must
96
+ then be complete — at least one output and exactly one destination.
97
+ That is the only way to drop an action's thumbnails or metadata, since
98
+ a repeated or map field sent empty reads as "not sent". For the same
99
+ reason ``managed=False`` does not turn managed storage off; it leaves
100
+ the destination alone, so send ``output_origin_id`` instead.
101
+
102
+ The whole response is returned: a rotation puts the new secret on it
103
+ (once, and the previous secret keeps working for 24 hours), and
104
+ switching a paused rule back on reports how many deliveries it declined
105
+ while off in ``events_skipped_while_disabled``.
106
+ """
107
+ req = fill_from_dict(ingest_rule_pb2.UpdateIngestRuleRequest(), kwargs)
108
+ return self._t.unary(_SERVICE, "Update", req, ingest_rule_pb2.UpdateIngestRuleResponse())
109
+
110
+ def delete(self, id: str, opts: CallOptions | None = None) -> ingest_rule_pb2.IngestRule:
111
+ """Delete a rule; its endpoint stops accepting events immediately.
112
+
113
+ The events it already received are kept, and the rule as it stood at
114
+ deletion is returned.
115
+ """
116
+ req = ingest_rule_pb2.DeleteIngestRuleRequest(id=id)
117
+ return self._t.unary(
118
+ _SERVICE, "Delete", req, ingest_rule_pb2.DeleteIngestRuleResponse(), opts
119
+ ).rule
120
+
121
+ def list_events(
122
+ self,
123
+ *,
124
+ rule_id: str | None = None,
125
+ status: int | str | None = None,
126
+ app_id: str | None = None,
127
+ limit: int | None = None,
128
+ opts: CallOptions | None = None,
129
+ ) -> Page[ingest_rule_pb2.StorageEvent]:
130
+ """Every delivery received and what came of it, newest first.
131
+
132
+ ``status`` accepts the simplified wire string (``"skipped"``) as well as
133
+ the generated constant.
134
+ """
135
+ resolved_status = (
136
+ None
137
+ if status is None
138
+ else resolve_enum(status, ingest_rule_pb2.StorageEventStatus.DESCRIPTOR)
139
+ )
140
+
141
+ def fetch(cursor: str | None) -> PageContents[ingest_rule_pb2.StorageEvent]:
142
+ req = ingest_rule_pb2.ListIngestEventsRequest()
143
+ if rule_id is not None:
144
+ req.rule_id = rule_id
145
+ if resolved_status is not None:
146
+ # resolve_enum returns a plain int; the field is typed as the enum.
147
+ req.status = cast("ingest_rule_pb2.StorageEventStatus", resolved_status)
148
+ if app_id is not None:
149
+ req.app_id = app_id
150
+ assign_pagination(req.pagination, limit=limit, cursor=cursor)
151
+ res = self._t.unary(
152
+ _SERVICE, "ListEvents", req, ingest_rule_pb2.ListIngestEventsResponse(), opts
153
+ )
154
+ return PageContents(
155
+ items=list(res.events), next_cursor=res.pagination.next_cursor or None
156
+ )
157
+
158
+ return Page(fetch)
159
+
160
+ def test(self, **kwargs: Any) -> ingest_rule_pb2.TestIngestRuleResponse:
161
+ """Dry-run an object key against a rule. Nothing is stored, no job is created.
162
+
163
+ Reports whether the filters match and, when they do, the exact job
164
+ request the rule would submit. Supply ``etag`` when you want the
165
+ preview to name the idempotency key a real delivery would carry — the
166
+ key is derived from it.
167
+ """
168
+ req = fill_from_dict(ingest_rule_pb2.TestIngestRuleRequest(), kwargs)
169
+ return self._t.unary(_SERVICE, "Test", req, ingest_rule_pb2.TestIngestRuleResponse())
170
+
171
+ def replay_event(
172
+ self, event_id: str, opts: CallOptions | None = None
173
+ ) -> ingest_rule_pb2.StorageEvent:
174
+ """Re-queue a skipped or refused event (``sev_*``).
175
+
176
+ Deduplication is permanent: re-sending the event, or re-uploading the
177
+ same bytes, is absorbed and produces nothing. The event is reset rather
178
+ than duplicated, so it keeps its id and its history.
179
+ """
180
+ req = ingest_rule_pb2.ReplayIngestEventRequest(event_id=event_id)
181
+ return self._t.unary(
182
+ _SERVICE, "ReplayEvent", req, ingest_rule_pb2.ReplayIngestEventResponse(), opts
183
+ ).event
@@ -126,12 +126,22 @@ class WebhookEndpoints:
126
126
  def list(
127
127
  self,
128
128
  *,
129
- app_id: str,
129
+ app_id: str | None = None,
130
130
  limit: int | None = None,
131
131
  opts: CallOptions | None = None,
132
132
  ) -> Page[webhook_pb2.WebhookEndpoint]:
133
+ """List webhook endpoints, newest first.
134
+
135
+ ``app_id`` is an optional filter. An API-key caller may omit it and get
136
+ their key's own app; passing a different app's ID is refused with
137
+ ``permission_denied``. A portal user who omits it gets the endpoints of
138
+ every app in the organization.
139
+ """
140
+
133
141
  def fetch(cursor: str | None) -> PageContents[webhook_pb2.WebhookEndpoint]:
134
- req = webhook_pb2.ListWebhookEndpointsRequest(app_id=app_id)
142
+ req = webhook_pb2.ListWebhookEndpointsRequest()
143
+ if app_id is not None:
144
+ req.app_id = app_id
135
145
  pagination = common_pb2.PaginationRequest()
136
146
  if limit is not None:
137
147
  pagination.limit = limit