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.
- {transcodely-0.3.7 → transcodely-0.3.9}/PKG-INFO +169 -6
- {transcodely-0.3.7 → transcodely-0.3.9}/README.md +164 -1
- {transcodely-0.3.7 → transcodely-0.3.9}/pyproject.toml +7 -5
- {transcodely-0.3.7 → transcodely-0.3.9}/src/transcodely/__init__.py +8 -0
- {transcodely-0.3.7 → transcodely-0.3.9}/src/transcodely/client.py +9 -0
- transcodely-0.3.9/src/transcodely/resources/billing.py +266 -0
- {transcodely-0.3.7 → transcodely-0.3.9}/src/transcodely/resources/events.py +12 -2
- transcodely-0.3.9/src/transcodely/resources/ingest_rules.py +183 -0
- {transcodely-0.3.7 → transcodely-0.3.9}/src/transcodely/resources/webhook_endpoints.py +12 -2
- {transcodely-0.3.7 → transcodely-0.3.9}/src/transcodely/types.py +119 -5
- {transcodely-0.3.7 → transcodely-0.3.9}/src/transcodely/v1/api_key_pb2.py +19 -19
- transcodely-0.3.9/src/transcodely/v1/app_pb2.py +129 -0
- {transcodely-0.3.7 → transcodely-0.3.9}/src/transcodely/v1/app_pb2.pyi +36 -2
- transcodely-0.3.9/src/transcodely/v1/billing_pb2.py +109 -0
- {transcodely-0.3.7 → transcodely-0.3.9}/src/transcodely/v1/billing_pb2.pyi +148 -2
- {transcodely-0.3.7 → transcodely-0.3.9}/src/transcodely/v1/common_pb2.py +5 -1
- {transcodely-0.3.7 → transcodely-0.3.9}/src/transcodely/v1/common_pb2.pyi +30 -0
- {transcodely-0.3.7 → transcodely-0.3.9}/src/transcodely/v1/content_aware_pb2.py +2 -2
- transcodely-0.3.9/src/transcodely/v1/ingest_rule_pb2.py +157 -0
- transcodely-0.3.9/src/transcodely/v1/ingest_rule_pb2.pyi +305 -0
- transcodely-0.3.9/src/transcodely/v1/job_pb2.py +250 -0
- {transcodely-0.3.7 → transcodely-0.3.9}/src/transcodely/v1/job_pb2.pyi +102 -2
- transcodely-0.3.9/src/transcodely/v1/organization_pb2.py +81 -0
- {transcodely-0.3.7 → transcodely-0.3.9}/src/transcodely/v1/organization_pb2.pyi +4 -2
- {transcodely-0.3.7 → transcodely-0.3.9}/src/transcodely/v1/streaming_pb2.py +9 -9
- transcodely-0.3.9/src/transcodely/v1/subtitles_pb2.py +75 -0
- transcodely-0.3.9/src/transcodely/v1/video_pb2.py +210 -0
- {transcodely-0.3.7 → transcodely-0.3.9}/src/transcodely/v1/video_pb2.pyi +6 -2
- {transcodely-0.3.7 → transcodely-0.3.9}/src/transcodely/v1/webhook_pb2.py +4 -4
- {transcodely-0.3.7 → transcodely-0.3.9}/src/transcodely/version.py +1 -1
- transcodely-0.3.7/src/transcodely/resources/billing.py +0 -138
- transcodely-0.3.7/src/transcodely/v1/app_pb2.py +0 -111
- transcodely-0.3.7/src/transcodely/v1/billing_pb2.py +0 -81
- transcodely-0.3.7/src/transcodely/v1/job_pb2.py +0 -236
- transcodely-0.3.7/src/transcodely/v1/organization_pb2.py +0 -81
- transcodely-0.3.7/src/transcodely/v1/subtitles_pb2.py +0 -75
- transcodely-0.3.7/src/transcodely/v1/video_pb2.py +0 -210
- {transcodely-0.3.7 → transcodely-0.3.9}/.gitignore +0 -0
- {transcodely-0.3.7 → transcodely-0.3.9}/LICENSE +0 -0
- {transcodely-0.3.7 → transcodely-0.3.9}/src/buf/__init__.py +0 -0
- {transcodely-0.3.7 → transcodely-0.3.9}/src/buf/validate/__init__.py +0 -0
- {transcodely-0.3.7 → transcodely-0.3.9}/src/buf/validate/validate_pb2.py +0 -0
- {transcodely-0.3.7 → transcodely-0.3.9}/src/buf/validate/validate_pb2.pyi +0 -0
- {transcodely-0.3.7 → transcodely-0.3.9}/src/transcodely/_codec/__init__.py +0 -0
- {transcodely-0.3.7 → transcodely-0.3.9}/src/transcodely/_codec/json_codec.py +0 -0
- {transcodely-0.3.7 → transcodely-0.3.9}/src/transcodely/_transport/__init__.py +0 -0
- {transcodely-0.3.7 → transcodely-0.3.9}/src/transcodely/_transport/error_mapping.py +0 -0
- {transcodely-0.3.7 → transcodely-0.3.9}/src/transcodely/_transport/headers.py +0 -0
- {transcodely-0.3.7 → transcodely-0.3.9}/src/transcodely/_transport/transport.py +0 -0
- {transcodely-0.3.7 → transcodely-0.3.9}/src/transcodely/errors.py +0 -0
- {transcodely-0.3.7 → transcodely-0.3.9}/src/transcodely/pagination.py +0 -0
- {transcodely-0.3.7 → transcodely-0.3.9}/src/transcodely/py.typed +0 -0
- {transcodely-0.3.7 → transcodely-0.3.9}/src/transcodely/resources/__init__.py +0 -0
- {transcodely-0.3.7 → transcodely-0.3.9}/src/transcodely/resources/_helpers.py +0 -0
- {transcodely-0.3.7 → transcodely-0.3.9}/src/transcodely/resources/api_keys.py +0 -0
- {transcodely-0.3.7 → transcodely-0.3.9}/src/transcodely/resources/apps.py +0 -0
- {transcodely-0.3.7 → transcodely-0.3.9}/src/transcodely/resources/health.py +0 -0
- {transcodely-0.3.7 → transcodely-0.3.9}/src/transcodely/resources/jobs.py +0 -0
- {transcodely-0.3.7 → transcodely-0.3.9}/src/transcodely/resources/memberships.py +0 -0
- {transcodely-0.3.7 → transcodely-0.3.9}/src/transcodely/resources/organizations.py +0 -0
- {transcodely-0.3.7 → transcodely-0.3.9}/src/transcodely/resources/origins.py +0 -0
- {transcodely-0.3.7 → transcodely-0.3.9}/src/transcodely/resources/presets.py +0 -0
- {transcodely-0.3.7 → transcodely-0.3.9}/src/transcodely/resources/users.py +0 -0
- {transcodely-0.3.7 → transcodely-0.3.9}/src/transcodely/resources/videos.py +0 -0
- {transcodely-0.3.7 → transcodely-0.3.9}/src/transcodely/streaming.py +0 -0
- {transcodely-0.3.7 → transcodely-0.3.9}/src/transcodely/v1/api_key_pb2.pyi +0 -0
- {transcodely-0.3.7 → transcodely-0.3.9}/src/transcodely/v1/codec_av1_pb2.py +0 -0
- {transcodely-0.3.7 → transcodely-0.3.9}/src/transcodely/v1/codec_av1_pb2.pyi +0 -0
- {transcodely-0.3.7 → transcodely-0.3.9}/src/transcodely/v1/codec_h264_pb2.py +0 -0
- {transcodely-0.3.7 → transcodely-0.3.9}/src/transcodely/v1/codec_h264_pb2.pyi +0 -0
- {transcodely-0.3.7 → transcodely-0.3.9}/src/transcodely/v1/codec_h265_pb2.py +0 -0
- {transcodely-0.3.7 → transcodely-0.3.9}/src/transcodely/v1/codec_h265_pb2.pyi +0 -0
- {transcodely-0.3.7 → transcodely-0.3.9}/src/transcodely/v1/codec_vp9_pb2.py +0 -0
- {transcodely-0.3.7 → transcodely-0.3.9}/src/transcodely/v1/codec_vp9_pb2.pyi +0 -0
- {transcodely-0.3.7 → transcodely-0.3.9}/src/transcodely/v1/content_aware_pb2.pyi +0 -0
- {transcodely-0.3.7 → transcodely-0.3.9}/src/transcodely/v1/drm_pb2.py +0 -0
- {transcodely-0.3.7 → transcodely-0.3.9}/src/transcodely/v1/drm_pb2.pyi +0 -0
- {transcodely-0.3.7 → transcodely-0.3.9}/src/transcodely/v1/hdr_pb2.py +0 -0
- {transcodely-0.3.7 → transcodely-0.3.9}/src/transcodely/v1/hdr_pb2.pyi +0 -0
- {transcodely-0.3.7 → transcodely-0.3.9}/src/transcodely/v1/health_pb2.py +0 -0
- {transcodely-0.3.7 → transcodely-0.3.9}/src/transcodely/v1/health_pb2.pyi +0 -0
- {transcodely-0.3.7 → transcodely-0.3.9}/src/transcodely/v1/media_pb2.py +0 -0
- {transcodely-0.3.7 → transcodely-0.3.9}/src/transcodely/v1/media_pb2.pyi +0 -0
- {transcodely-0.3.7 → transcodely-0.3.9}/src/transcodely/v1/membership_pb2.py +0 -0
- {transcodely-0.3.7 → transcodely-0.3.9}/src/transcodely/v1/membership_pb2.pyi +0 -0
- {transcodely-0.3.7 → transcodely-0.3.9}/src/transcodely/v1/origin_pb2.py +0 -0
- {transcodely-0.3.7 → transcodely-0.3.9}/src/transcodely/v1/origin_pb2.pyi +0 -0
- {transcodely-0.3.7 → transcodely-0.3.9}/src/transcodely/v1/preset_pb2.py +0 -0
- {transcodely-0.3.7 → transcodely-0.3.9}/src/transcodely/v1/preset_pb2.pyi +0 -0
- {transcodely-0.3.7 → transcodely-0.3.9}/src/transcodely/v1/streaming_pb2.pyi +0 -0
- {transcodely-0.3.7 → transcodely-0.3.9}/src/transcodely/v1/subtitles_pb2.pyi +0 -0
- {transcodely-0.3.7 → transcodely-0.3.9}/src/transcodely/v1/thumbnails_pb2.py +0 -0
- {transcodely-0.3.7 → transcodely-0.3.9}/src/transcodely/v1/thumbnails_pb2.pyi +0 -0
- {transcodely-0.3.7 → transcodely-0.3.9}/src/transcodely/v1/user_pb2.py +0 -0
- {transcodely-0.3.7 → transcodely-0.3.9}/src/transcodely/v1/user_pb2.pyi +0 -0
- {transcodely-0.3.7 → transcodely-0.3.9}/src/transcodely/v1/watermark_pb2.py +0 -0
- {transcodely-0.3.7 → transcodely-0.3.9}/src/transcodely/v1/watermark_pb2.pyi +0 -0
- {transcodely-0.3.7 → transcodely-0.3.9}/src/transcodely/v1/webhook_pb2.pyi +0 -0
- {transcodely-0.3.7 → transcodely-0.3.9}/src/transcodely/webhooks/__init__.py +0 -0
- {transcodely-0.3.7 → transcodely-0.3.9}/src/transcodely/webhooks/construct_event.py +0 -0
- {transcodely-0.3.7 → transcodely-0.3.9}/src/transcodely/webhooks/signature.py +0 -0
- {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.
|
|
4
|
-
Summary: Official Python SDK for
|
|
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
|
|
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
|
|
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.
|
|
8
|
-
description = "Official Python SDK for
|
|
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", "
|
|
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:
|