transcodely 0.3.1__tar.gz → 0.3.3__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.1 → transcodely-0.3.3}/PKG-INFO +87 -4
- {transcodely-0.3.1 → transcodely-0.3.3}/README.md +85 -2
- {transcodely-0.3.1 → transcodely-0.3.3}/pyproject.toml +2 -2
- {transcodely-0.3.1 → transcodely-0.3.3}/src/transcodely/__init__.py +41 -21
- {transcodely-0.3.1 → transcodely-0.3.3}/src/transcodely/_codec/json_codec.py +2 -3
- {transcodely-0.3.1 → transcodely-0.3.3}/src/transcodely/_transport/error_mapping.py +8 -7
- {transcodely-0.3.1 → transcodely-0.3.3}/src/transcodely/_transport/transport.py +29 -25
- {transcodely-0.3.1 → transcodely-0.3.3}/src/transcodely/client.py +38 -21
- {transcodely-0.3.1 → transcodely-0.3.3}/src/transcodely/errors.py +12 -12
- {transcodely-0.3.1 → transcodely-0.3.3}/src/transcodely/pagination.py +8 -8
- {transcodely-0.3.1 → transcodely-0.3.3}/src/transcodely/resources/_helpers.py +6 -5
- {transcodely-0.3.1 → transcodely-0.3.3}/src/transcodely/resources/api_keys.py +6 -6
- transcodely-0.3.3/src/transcodely/resources/apps.py +116 -0
- transcodely-0.3.3/src/transcodely/resources/billing.py +90 -0
- {transcodely-0.3.1 → transcodely-0.3.3}/src/transcodely/resources/events.py +10 -10
- {transcodely-0.3.1 → transcodely-0.3.3}/src/transcodely/resources/health.py +1 -3
- {transcodely-0.3.1 → transcodely-0.3.3}/src/transcodely/resources/jobs.py +57 -29
- {transcodely-0.3.1 → transcodely-0.3.3}/src/transcodely/resources/memberships.py +9 -8
- {transcodely-0.3.1 → transcodely-0.3.3}/src/transcodely/resources/organizations.py +6 -6
- {transcodely-0.3.1 → transcodely-0.3.3}/src/transcodely/resources/origins.py +6 -6
- {transcodely-0.3.1 → transcodely-0.3.3}/src/transcodely/resources/presets.py +7 -7
- {transcodely-0.3.1 → transcodely-0.3.3}/src/transcodely/resources/users.py +9 -8
- {transcodely-0.3.1 → transcodely-0.3.3}/src/transcodely/resources/videos.py +48 -8
- {transcodely-0.3.1 → transcodely-0.3.3}/src/transcodely/resources/webhook_endpoints.py +32 -31
- {transcodely-0.3.1 → transcodely-0.3.3}/src/transcodely/streaming.py +3 -2
- {transcodely-0.3.1 → transcodely-0.3.3}/src/transcodely/types.py +260 -182
- transcodely-0.3.3/src/transcodely/v1/app_pb2.py +111 -0
- {transcodely-0.3.1 → transcodely-0.3.3}/src/transcodely/v1/app_pb2.pyi +48 -4
- transcodely-0.3.3/src/transcodely/v1/billing_pb2.py +65 -0
- transcodely-0.3.3/src/transcodely/v1/billing_pb2.pyi +131 -0
- transcodely-0.3.3/src/transcodely/v1/job_pb2.py +226 -0
- {transcodely-0.3.1 → transcodely-0.3.3}/src/transcodely/v1/job_pb2.pyi +74 -10
- {transcodely-0.3.1 → transcodely-0.3.3}/src/transcodely/v1/subtitles_pb2.py +13 -7
- {transcodely-0.3.1 → transcodely-0.3.3}/src/transcodely/v1/subtitles_pb2.pyi +60 -3
- {transcodely-0.3.1 → transcodely-0.3.3}/src/transcodely/v1/thumbnails_pb2.py +14 -8
- {transcodely-0.3.1 → transcodely-0.3.3}/src/transcodely/v1/thumbnails_pb2.pyi +14 -4
- transcodely-0.3.3/src/transcodely/v1/video_pb2.py +210 -0
- {transcodely-0.3.1 → transcodely-0.3.3}/src/transcodely/v1/video_pb2.pyi +168 -10
- transcodely-0.3.3/src/transcodely/v1/watermark_pb2.py +57 -0
- transcodely-0.3.3/src/transcodely/v1/watermark_pb2.pyi +56 -0
- transcodely-0.3.3/src/transcodely/v1/webhook_pb2.py +155 -0
- {transcodely-0.3.1 → transcodely-0.3.3}/src/transcodely/v1/webhook_pb2.pyi +22 -6
- {transcodely-0.3.1 → transcodely-0.3.3}/src/transcodely/version.py +1 -1
- {transcodely-0.3.1 → transcodely-0.3.3}/src/transcodely/webhooks/construct_event.py +5 -4
- {transcodely-0.3.1 → transcodely-0.3.3}/src/transcodely/webhooks/signature.py +6 -6
- {transcodely-0.3.1 → transcodely-0.3.3}/src/transcodely/webhooks/types.py +34 -9
- transcodely-0.3.1/src/transcodely/resources/apps.py +0 -71
- transcodely-0.3.1/src/transcodely/v1/app_pb2.py +0 -97
- transcodely-0.3.1/src/transcodely/v1/job_pb2.py +0 -205
- transcodely-0.3.1/src/transcodely/v1/video_pb2.py +0 -168
- transcodely-0.3.1/src/transcodely/v1/webhook_pb2.py +0 -151
- {transcodely-0.3.1 → transcodely-0.3.3}/.gitignore +0 -0
- {transcodely-0.3.1 → transcodely-0.3.3}/LICENSE +0 -0
- {transcodely-0.3.1 → transcodely-0.3.3}/src/buf/__init__.py +0 -0
- {transcodely-0.3.1 → transcodely-0.3.3}/src/buf/validate/__init__.py +0 -0
- {transcodely-0.3.1 → transcodely-0.3.3}/src/buf/validate/validate_pb2.py +0 -0
- {transcodely-0.3.1 → transcodely-0.3.3}/src/buf/validate/validate_pb2.pyi +0 -0
- {transcodely-0.3.1 → transcodely-0.3.3}/src/transcodely/_codec/__init__.py +0 -0
- {transcodely-0.3.1 → transcodely-0.3.3}/src/transcodely/_transport/__init__.py +0 -0
- {transcodely-0.3.1 → transcodely-0.3.3}/src/transcodely/_transport/headers.py +0 -0
- {transcodely-0.3.1 → transcodely-0.3.3}/src/transcodely/py.typed +0 -0
- {transcodely-0.3.1 → transcodely-0.3.3}/src/transcodely/resources/__init__.py +0 -0
- {transcodely-0.3.1 → transcodely-0.3.3}/src/transcodely/v1/api_key_pb2.py +0 -0
- {transcodely-0.3.1 → transcodely-0.3.3}/src/transcodely/v1/api_key_pb2.pyi +0 -0
- {transcodely-0.3.1 → transcodely-0.3.3}/src/transcodely/v1/codec_av1_pb2.py +0 -0
- {transcodely-0.3.1 → transcodely-0.3.3}/src/transcodely/v1/codec_av1_pb2.pyi +0 -0
- {transcodely-0.3.1 → transcodely-0.3.3}/src/transcodely/v1/codec_h264_pb2.py +0 -0
- {transcodely-0.3.1 → transcodely-0.3.3}/src/transcodely/v1/codec_h264_pb2.pyi +0 -0
- {transcodely-0.3.1 → transcodely-0.3.3}/src/transcodely/v1/codec_h265_pb2.py +0 -0
- {transcodely-0.3.1 → transcodely-0.3.3}/src/transcodely/v1/codec_h265_pb2.pyi +0 -0
- {transcodely-0.3.1 → transcodely-0.3.3}/src/transcodely/v1/codec_vp9_pb2.py +0 -0
- {transcodely-0.3.1 → transcodely-0.3.3}/src/transcodely/v1/codec_vp9_pb2.pyi +0 -0
- {transcodely-0.3.1 → transcodely-0.3.3}/src/transcodely/v1/common_pb2.py +0 -0
- {transcodely-0.3.1 → transcodely-0.3.3}/src/transcodely/v1/common_pb2.pyi +0 -0
- {transcodely-0.3.1 → transcodely-0.3.3}/src/transcodely/v1/content_aware_pb2.py +0 -0
- {transcodely-0.3.1 → transcodely-0.3.3}/src/transcodely/v1/content_aware_pb2.pyi +0 -0
- {transcodely-0.3.1 → transcodely-0.3.3}/src/transcodely/v1/drm_pb2.py +0 -0
- {transcodely-0.3.1 → transcodely-0.3.3}/src/transcodely/v1/drm_pb2.pyi +0 -0
- {transcodely-0.3.1 → transcodely-0.3.3}/src/transcodely/v1/hdr_pb2.py +0 -0
- {transcodely-0.3.1 → transcodely-0.3.3}/src/transcodely/v1/hdr_pb2.pyi +0 -0
- {transcodely-0.3.1 → transcodely-0.3.3}/src/transcodely/v1/health_pb2.py +0 -0
- {transcodely-0.3.1 → transcodely-0.3.3}/src/transcodely/v1/health_pb2.pyi +0 -0
- {transcodely-0.3.1 → transcodely-0.3.3}/src/transcodely/v1/media_pb2.py +0 -0
- {transcodely-0.3.1 → transcodely-0.3.3}/src/transcodely/v1/media_pb2.pyi +0 -0
- {transcodely-0.3.1 → transcodely-0.3.3}/src/transcodely/v1/membership_pb2.py +0 -0
- {transcodely-0.3.1 → transcodely-0.3.3}/src/transcodely/v1/membership_pb2.pyi +0 -0
- {transcodely-0.3.1 → transcodely-0.3.3}/src/transcodely/v1/organization_pb2.py +0 -0
- {transcodely-0.3.1 → transcodely-0.3.3}/src/transcodely/v1/organization_pb2.pyi +0 -0
- {transcodely-0.3.1 → transcodely-0.3.3}/src/transcodely/v1/origin_pb2.py +0 -0
- {transcodely-0.3.1 → transcodely-0.3.3}/src/transcodely/v1/origin_pb2.pyi +0 -0
- {transcodely-0.3.1 → transcodely-0.3.3}/src/transcodely/v1/preset_pb2.py +0 -0
- {transcodely-0.3.1 → transcodely-0.3.3}/src/transcodely/v1/preset_pb2.pyi +0 -0
- {transcodely-0.3.1 → transcodely-0.3.3}/src/transcodely/v1/streaming_pb2.py +0 -0
- {transcodely-0.3.1 → transcodely-0.3.3}/src/transcodely/v1/streaming_pb2.pyi +0 -0
- {transcodely-0.3.1 → transcodely-0.3.3}/src/transcodely/v1/user_pb2.py +0 -0
- {transcodely-0.3.1 → transcodely-0.3.3}/src/transcodely/v1/user_pb2.pyi +0 -0
- {transcodely-0.3.1 → transcodely-0.3.3}/src/transcodely/webhooks/__init__.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: transcodely
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.3
|
|
4
4
|
Summary: Official Python SDK for the Transcodely video transcoding API
|
|
5
5
|
Project-URL: Homepage, https://transcodely.com
|
|
6
6
|
Project-URL: Documentation, https://transcodely.com/docs
|
|
@@ -30,7 +30,7 @@ Provides-Extra: dev
|
|
|
30
30
|
Requires-Dist: mypy>=1.10; extra == 'dev'
|
|
31
31
|
Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
|
|
32
32
|
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
33
|
-
Requires-Dist: ruff>=0.
|
|
33
|
+
Requires-Dist: ruff>=0.16; extra == 'dev'
|
|
34
34
|
Provides-Extra: fast
|
|
35
35
|
Requires-Dist: orjson>=3.10; extra == 'fast'
|
|
36
36
|
Description-Content-Type: text/markdown
|
|
@@ -52,6 +52,7 @@ from transcodely import Transcodely
|
|
|
52
52
|
with Transcodely(api_key=os.environ["TRANSCODELY_API_KEY"]) as client:
|
|
53
53
|
job = client.jobs.create(
|
|
54
54
|
input_url="https://example.com/source.mp4",
|
|
55
|
+
managed=True, # write outputs to Transcodely-managed storage
|
|
55
56
|
outputs=[{
|
|
56
57
|
"type": "hls",
|
|
57
58
|
"video": [
|
|
@@ -59,6 +60,10 @@ with Transcodely(api_key=os.environ["TRANSCODELY_API_KEY"]) as client:
|
|
|
59
60
|
{"codec": "h264", "resolution": "720p"},
|
|
60
61
|
],
|
|
61
62
|
}],
|
|
63
|
+
# Optional: encode only a sub-range of the input. Applies job-wide and
|
|
64
|
+
# reduces cost (billing keys off the produced output duration). Omit
|
|
65
|
+
# "end_seconds" (or leave 0) to encode through to the end of the input.
|
|
66
|
+
clip={"start_seconds": 2, "end_seconds": 7},
|
|
62
67
|
)
|
|
63
68
|
print(job.id) # "job_a1b2c3d4e5f6"
|
|
64
69
|
|
|
@@ -70,6 +75,35 @@ with Transcodely(api_key=os.environ["TRANSCODELY_API_KEY"]) as client:
|
|
|
70
75
|
|
|
71
76
|
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.
|
|
72
77
|
|
|
78
|
+
## AI captions
|
|
79
|
+
|
|
80
|
+
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`.
|
|
81
|
+
|
|
82
|
+
```python
|
|
83
|
+
# Generate captions while transcoding a new source.
|
|
84
|
+
client.jobs.create(
|
|
85
|
+
input_url="https://example.com/source.mp4",
|
|
86
|
+
managed=True,
|
|
87
|
+
outputs=[{
|
|
88
|
+
"type": "hls",
|
|
89
|
+
"video": [{"codec": "h264", "resolution": "1080p"}],
|
|
90
|
+
"subtitle_tracks": [{"operation": "generate", "language": "auto"}],
|
|
91
|
+
}],
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
# Retro-caption a video you've already hosted: reference it by input_video_id and
|
|
95
|
+
# request a single captions-only output (no video encode).
|
|
96
|
+
job = client.jobs.create(
|
|
97
|
+
input_video_id="vid_a1b2c3d4e5f6g7",
|
|
98
|
+
outputs=[{"subtitle_tracks": [{"operation": "generate"}]}],
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
for result in job.subtitle_results:
|
|
102
|
+
print(result.language, result.auto_generated, result.url)
|
|
103
|
+
for fee in job.fees:
|
|
104
|
+
print(fee.fee_type, fee.amount, fee.currency) # "captions" 0.51 "eur"
|
|
105
|
+
```
|
|
106
|
+
|
|
73
107
|
## Authentication
|
|
74
108
|
|
|
75
109
|
```python
|
|
@@ -82,7 +116,7 @@ API keys are opaque `ak_`-prefixed secrets — pass the full value shown once at
|
|
|
82
116
|
|
|
83
117
|
```python
|
|
84
118
|
client.jobs # create / get / list / cancel / confirm / watch
|
|
85
|
-
client.videos # upload helpers, multipart, get / list / update / delete / watch
|
|
119
|
+
client.videos # upload helpers, multipart, create_from_url, get / list / update / delete / watch, get_stats / list_top_videos
|
|
86
120
|
client.presets # create / get / get_by_slug / list / update / duplicate / archive
|
|
87
121
|
client.origins # create / get / list / update / validate / archive
|
|
88
122
|
client.apps # create / get / list / update / archive / enable_hosting
|
|
@@ -96,6 +130,55 @@ client.events # retrieve / list / resend
|
|
|
96
130
|
client.webhooks # construct_event / verify_signature (signature-verification helpers)
|
|
97
131
|
```
|
|
98
132
|
|
|
133
|
+
## Videos
|
|
134
|
+
|
|
135
|
+
### One-call ingest from a URL
|
|
136
|
+
|
|
137
|
+
`create_from_url` skips the upload dance entirely: give it a publicly-reachable
|
|
138
|
+
`http(s)` URL and the worker fetches it at transcode time. Requires an app with
|
|
139
|
+
managed hosting enabled (`client.apps.enable_hosting(...)`).
|
|
140
|
+
|
|
141
|
+
```python
|
|
142
|
+
video = client.videos.create_from_url(
|
|
143
|
+
app_id="app_default000",
|
|
144
|
+
url="https://example.com/source.mp4",
|
|
145
|
+
title="Product demo",
|
|
146
|
+
)
|
|
147
|
+
print(video.id, video.status) # "processing" — no upload step
|
|
148
|
+
|
|
149
|
+
for event in client.videos.watch(video.id):
|
|
150
|
+
if event.video.status == "ready":
|
|
151
|
+
print(event.video.playback_url, event.video.embed_url)
|
|
152
|
+
break
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
No `video.uploaded` event fires for URL ingest (no bytes are uploaded to the
|
|
156
|
+
API) — subscribe to `video.ready` / `video.failed` instead to know when it's
|
|
157
|
+
playable.
|
|
158
|
+
|
|
159
|
+
### Playback analytics
|
|
160
|
+
|
|
161
|
+
`get_stats` returns a single video's playback stats — plays, watch time, and
|
|
162
|
+
unique viewers — aggregated per UTC day, plus range totals. `list_top_videos`
|
|
163
|
+
ranks an app's videos by plays over a date range. Both accept optional
|
|
164
|
+
`start_date` / `end_date` (`YYYY-MM-DD`, inclusive); stats are best-effort and
|
|
165
|
+
roll up hourly, so recent activity can lag by up to an hour.
|
|
166
|
+
|
|
167
|
+
```python
|
|
168
|
+
stats = client.videos.get_stats(
|
|
169
|
+
video_id="vid_abc123def456",
|
|
170
|
+
start_date="2026-07-01",
|
|
171
|
+
end_date="2026-07-16",
|
|
172
|
+
)
|
|
173
|
+
for day in stats.daily:
|
|
174
|
+
print(day.date, day.plays, day.watch_seconds, day.unique_viewers)
|
|
175
|
+
print("range totals:", stats.totals.plays, stats.totals.watch_seconds)
|
|
176
|
+
|
|
177
|
+
top = client.videos.list_top_videos(app_id="app_default000", limit=10)
|
|
178
|
+
for video in top.items:
|
|
179
|
+
print(video.video_id, video.title, video.plays, video.watch_seconds)
|
|
180
|
+
```
|
|
181
|
+
|
|
99
182
|
## Origins
|
|
100
183
|
|
|
101
184
|
An origin tells Transcodely where to read source media from and where to write outputs. Every origin belongs to a single provider; pass exactly one provider-config field (`s3`, `gcs`, `http`, or `r2`) on create.
|
|
@@ -314,7 +397,7 @@ for event in client.events.list(app_id="app_123").auto_paging_iter():
|
|
|
314
397
|
client.events.resend("evt_123") # re-queue delivery to all subscribed endpoints
|
|
315
398
|
```
|
|
316
399
|
|
|
317
|
-
The
|
|
400
|
+
The 17 event types are `job.created`, `job.succeeded`, `job.failed`, `job.canceled`, `job.progress`, `output.created`, `output.ready`, `output.failed`, `output.progress`, `video.uploaded`, `video.ready`, `video.failed`, `video.deleted`, `app.created`, `app.updated`, `app.spend_limit_warning`, and `app.spend_limit_exceeded`. Subscribe to `"*"` to receive all of them (including ones added later). An unrecognized future type still verifies; its `event.data` is left as a plain `dict`. The two `app.spend_limit_*` events also carry a plain `dict` (`app_id`, `period_start`, `period_end`, `limit_eur`, `spent_eur`, `threshold_pct`, `currency`), not a resource snapshot.
|
|
318
401
|
|
|
319
402
|
## Configuration
|
|
320
403
|
|
|
@@ -15,6 +15,7 @@ from transcodely import Transcodely
|
|
|
15
15
|
with Transcodely(api_key=os.environ["TRANSCODELY_API_KEY"]) as client:
|
|
16
16
|
job = client.jobs.create(
|
|
17
17
|
input_url="https://example.com/source.mp4",
|
|
18
|
+
managed=True, # write outputs to Transcodely-managed storage
|
|
18
19
|
outputs=[{
|
|
19
20
|
"type": "hls",
|
|
20
21
|
"video": [
|
|
@@ -22,6 +23,10 @@ with Transcodely(api_key=os.environ["TRANSCODELY_API_KEY"]) as client:
|
|
|
22
23
|
{"codec": "h264", "resolution": "720p"},
|
|
23
24
|
],
|
|
24
25
|
}],
|
|
26
|
+
# Optional: encode only a sub-range of the input. Applies job-wide and
|
|
27
|
+
# reduces cost (billing keys off the produced output duration). Omit
|
|
28
|
+
# "end_seconds" (or leave 0) to encode through to the end of the input.
|
|
29
|
+
clip={"start_seconds": 2, "end_seconds": 7},
|
|
25
30
|
)
|
|
26
31
|
print(job.id) # "job_a1b2c3d4e5f6"
|
|
27
32
|
|
|
@@ -33,6 +38,35 @@ with Transcodely(api_key=os.environ["TRANSCODELY_API_KEY"]) as client:
|
|
|
33
38
|
|
|
34
39
|
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.
|
|
35
40
|
|
|
41
|
+
## AI captions
|
|
42
|
+
|
|
43
|
+
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`.
|
|
44
|
+
|
|
45
|
+
```python
|
|
46
|
+
# Generate captions while transcoding a new source.
|
|
47
|
+
client.jobs.create(
|
|
48
|
+
input_url="https://example.com/source.mp4",
|
|
49
|
+
managed=True,
|
|
50
|
+
outputs=[{
|
|
51
|
+
"type": "hls",
|
|
52
|
+
"video": [{"codec": "h264", "resolution": "1080p"}],
|
|
53
|
+
"subtitle_tracks": [{"operation": "generate", "language": "auto"}],
|
|
54
|
+
}],
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
# Retro-caption a video you've already hosted: reference it by input_video_id and
|
|
58
|
+
# request a single captions-only output (no video encode).
|
|
59
|
+
job = client.jobs.create(
|
|
60
|
+
input_video_id="vid_a1b2c3d4e5f6g7",
|
|
61
|
+
outputs=[{"subtitle_tracks": [{"operation": "generate"}]}],
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
for result in job.subtitle_results:
|
|
65
|
+
print(result.language, result.auto_generated, result.url)
|
|
66
|
+
for fee in job.fees:
|
|
67
|
+
print(fee.fee_type, fee.amount, fee.currency) # "captions" 0.51 "eur"
|
|
68
|
+
```
|
|
69
|
+
|
|
36
70
|
## Authentication
|
|
37
71
|
|
|
38
72
|
```python
|
|
@@ -45,7 +79,7 @@ API keys are opaque `ak_`-prefixed secrets — pass the full value shown once at
|
|
|
45
79
|
|
|
46
80
|
```python
|
|
47
81
|
client.jobs # create / get / list / cancel / confirm / watch
|
|
48
|
-
client.videos # upload helpers, multipart, get / list / update / delete / watch
|
|
82
|
+
client.videos # upload helpers, multipart, create_from_url, get / list / update / delete / watch, get_stats / list_top_videos
|
|
49
83
|
client.presets # create / get / get_by_slug / list / update / duplicate / archive
|
|
50
84
|
client.origins # create / get / list / update / validate / archive
|
|
51
85
|
client.apps # create / get / list / update / archive / enable_hosting
|
|
@@ -59,6 +93,55 @@ client.events # retrieve / list / resend
|
|
|
59
93
|
client.webhooks # construct_event / verify_signature (signature-verification helpers)
|
|
60
94
|
```
|
|
61
95
|
|
|
96
|
+
## Videos
|
|
97
|
+
|
|
98
|
+
### One-call ingest from a URL
|
|
99
|
+
|
|
100
|
+
`create_from_url` skips the upload dance entirely: give it a publicly-reachable
|
|
101
|
+
`http(s)` URL and the worker fetches it at transcode time. Requires an app with
|
|
102
|
+
managed hosting enabled (`client.apps.enable_hosting(...)`).
|
|
103
|
+
|
|
104
|
+
```python
|
|
105
|
+
video = client.videos.create_from_url(
|
|
106
|
+
app_id="app_default000",
|
|
107
|
+
url="https://example.com/source.mp4",
|
|
108
|
+
title="Product demo",
|
|
109
|
+
)
|
|
110
|
+
print(video.id, video.status) # "processing" — no upload step
|
|
111
|
+
|
|
112
|
+
for event in client.videos.watch(video.id):
|
|
113
|
+
if event.video.status == "ready":
|
|
114
|
+
print(event.video.playback_url, event.video.embed_url)
|
|
115
|
+
break
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
No `video.uploaded` event fires for URL ingest (no bytes are uploaded to the
|
|
119
|
+
API) — subscribe to `video.ready` / `video.failed` instead to know when it's
|
|
120
|
+
playable.
|
|
121
|
+
|
|
122
|
+
### Playback analytics
|
|
123
|
+
|
|
124
|
+
`get_stats` returns a single video's playback stats — plays, watch time, and
|
|
125
|
+
unique viewers — aggregated per UTC day, plus range totals. `list_top_videos`
|
|
126
|
+
ranks an app's videos by plays over a date range. Both accept optional
|
|
127
|
+
`start_date` / `end_date` (`YYYY-MM-DD`, inclusive); stats are best-effort and
|
|
128
|
+
roll up hourly, so recent activity can lag by up to an hour.
|
|
129
|
+
|
|
130
|
+
```python
|
|
131
|
+
stats = client.videos.get_stats(
|
|
132
|
+
video_id="vid_abc123def456",
|
|
133
|
+
start_date="2026-07-01",
|
|
134
|
+
end_date="2026-07-16",
|
|
135
|
+
)
|
|
136
|
+
for day in stats.daily:
|
|
137
|
+
print(day.date, day.plays, day.watch_seconds, day.unique_viewers)
|
|
138
|
+
print("range totals:", stats.totals.plays, stats.totals.watch_seconds)
|
|
139
|
+
|
|
140
|
+
top = client.videos.list_top_videos(app_id="app_default000", limit=10)
|
|
141
|
+
for video in top.items:
|
|
142
|
+
print(video.video_id, video.title, video.plays, video.watch_seconds)
|
|
143
|
+
```
|
|
144
|
+
|
|
62
145
|
## Origins
|
|
63
146
|
|
|
64
147
|
An origin tells Transcodely where to read source media from and where to write outputs. Every origin belongs to a single provider; pass exactly one provider-config field (`s3`, `gcs`, `http`, or `r2`) on create.
|
|
@@ -277,7 +360,7 @@ for event in client.events.list(app_id="app_123").auto_paging_iter():
|
|
|
277
360
|
client.events.resend("evt_123") # re-queue delivery to all subscribed endpoints
|
|
278
361
|
```
|
|
279
362
|
|
|
280
|
-
The
|
|
363
|
+
The 17 event types are `job.created`, `job.succeeded`, `job.failed`, `job.canceled`, `job.progress`, `output.created`, `output.ready`, `output.failed`, `output.progress`, `video.uploaded`, `video.ready`, `video.failed`, `video.deleted`, `app.created`, `app.updated`, `app.spend_limit_warning`, and `app.spend_limit_exceeded`. Subscribe to `"*"` to receive all of them (including ones added later). An unrecognized future type still verifies; its `event.data` is left as a plain `dict`. The two `app.spend_limit_*` events also carry a plain `dict` (`app_id`, `period_start`, `period_end`, `limit_eur`, `spent_eur`, `threshold_pct`, `currency`), not a resource snapshot.
|
|
281
364
|
|
|
282
365
|
## Configuration
|
|
283
366
|
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "transcodely"
|
|
7
|
-
version = "0.3.
|
|
7
|
+
version = "0.3.3"
|
|
8
8
|
description = "Official Python SDK for the Transcodely video transcoding API"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = "MIT"
|
|
@@ -37,7 +37,7 @@ fast = ["orjson>=3.10"]
|
|
|
37
37
|
dev = [
|
|
38
38
|
"pytest>=8.0",
|
|
39
39
|
"pytest-asyncio>=0.23",
|
|
40
|
-
"ruff>=0.
|
|
40
|
+
"ruff>=0.16",
|
|
41
41
|
"mypy>=1.10",
|
|
42
42
|
]
|
|
43
43
|
|
|
@@ -47,19 +47,6 @@ from .errors import (
|
|
|
47
47
|
WebhookTimestampError,
|
|
48
48
|
)
|
|
49
49
|
from .pagination import Page, PageContents
|
|
50
|
-
from .version import API_VERSION, DEFAULT_BASE_URL, SDK_VERSION
|
|
51
|
-
from .webhooks import (
|
|
52
|
-
DEFAULT_TOLERANCE_SECONDS,
|
|
53
|
-
EVENT_ID_HEADER,
|
|
54
|
-
SIGNATURE_HEADER,
|
|
55
|
-
Event,
|
|
56
|
-
EventRequest,
|
|
57
|
-
EventType,
|
|
58
|
-
WebhookEvent,
|
|
59
|
-
Webhooks,
|
|
60
|
-
construct_event,
|
|
61
|
-
verify_signature,
|
|
62
|
-
)
|
|
63
50
|
|
|
64
51
|
# Headline message classes — most callers reach for these. Everything else lives
|
|
65
52
|
# in :mod:`transcodely.types`.
|
|
@@ -68,11 +55,18 @@ from .types import (
|
|
|
68
55
|
App,
|
|
69
56
|
AudioTrackConfig,
|
|
70
57
|
AutoABRConfig,
|
|
58
|
+
ChapterPoint,
|
|
59
|
+
ChapterResult,
|
|
60
|
+
ClipConfig,
|
|
71
61
|
ContentAwareConfig,
|
|
72
62
|
DASHConfig,
|
|
73
63
|
DRMConfig,
|
|
74
64
|
HDRConfig,
|
|
75
65
|
HLSConfig,
|
|
66
|
+
Invoice,
|
|
67
|
+
InvoiceLineItem,
|
|
68
|
+
InvoiceLineType,
|
|
69
|
+
InvoiceStatus,
|
|
76
70
|
Job,
|
|
77
71
|
JobOutput,
|
|
78
72
|
JobPriority,
|
|
@@ -87,6 +81,7 @@ from .types import (
|
|
|
87
81
|
PresetVariant,
|
|
88
82
|
Resolution,
|
|
89
83
|
StreamingConfig,
|
|
84
|
+
SubtitleResult,
|
|
90
85
|
SubtitleTrack,
|
|
91
86
|
ThumbnailResult,
|
|
92
87
|
ThumbnailSpec,
|
|
@@ -95,16 +90,36 @@ from .types import (
|
|
|
95
90
|
Video,
|
|
96
91
|
VideoCodec,
|
|
97
92
|
VideoRendition,
|
|
93
|
+
VideoTextTrack,
|
|
98
94
|
VideoVariant,
|
|
99
95
|
WatchEventType,
|
|
100
96
|
WatchJobResponse,
|
|
101
97
|
WatchVideoResponse,
|
|
98
|
+
WatermarkConfig,
|
|
102
99
|
WebhookDelivery,
|
|
103
100
|
WebhookEndpoint,
|
|
104
101
|
)
|
|
102
|
+
from .version import API_VERSION, DEFAULT_BASE_URL, SDK_VERSION
|
|
103
|
+
from .webhooks import (
|
|
104
|
+
DEFAULT_TOLERANCE_SECONDS,
|
|
105
|
+
EVENT_ID_HEADER,
|
|
106
|
+
SIGNATURE_HEADER,
|
|
107
|
+
Event,
|
|
108
|
+
EventRequest,
|
|
109
|
+
EventType,
|
|
110
|
+
WebhookEvent,
|
|
111
|
+
Webhooks,
|
|
112
|
+
construct_event,
|
|
113
|
+
verify_signature,
|
|
114
|
+
)
|
|
105
115
|
|
|
106
116
|
__all__ = [
|
|
107
117
|
"API_VERSION",
|
|
118
|
+
"DEFAULT_BASE_URL",
|
|
119
|
+
"DEFAULT_TOLERANCE_SECONDS",
|
|
120
|
+
"EVENT_ID_HEADER",
|
|
121
|
+
"SDK_VERSION",
|
|
122
|
+
"SIGNATURE_HEADER",
|
|
108
123
|
"APIConnectionError",
|
|
109
124
|
"APIError",
|
|
110
125
|
"APIKey",
|
|
@@ -112,21 +127,24 @@ __all__ = [
|
|
|
112
127
|
"AudioTrackConfig",
|
|
113
128
|
"AuthenticationError",
|
|
114
129
|
"AutoABRConfig",
|
|
130
|
+
"ChapterPoint",
|
|
131
|
+
"ChapterResult",
|
|
132
|
+
"ClipConfig",
|
|
115
133
|
"ConflictError",
|
|
116
|
-
"construct_event",
|
|
117
134
|
"ContentAwareConfig",
|
|
118
135
|
"DASHConfig",
|
|
119
|
-
"DEFAULT_BASE_URL",
|
|
120
|
-
"DEFAULT_TOLERANCE_SECONDS",
|
|
121
136
|
"DRMConfig",
|
|
122
137
|
"Event",
|
|
123
|
-
"EVENT_ID_HEADER",
|
|
124
138
|
"EventRequest",
|
|
125
139
|
"EventType",
|
|
126
140
|
"FieldViolation",
|
|
127
141
|
"HDRConfig",
|
|
128
142
|
"HLSConfig",
|
|
129
143
|
"InvalidRequestError",
|
|
144
|
+
"Invoice",
|
|
145
|
+
"InvoiceLineItem",
|
|
146
|
+
"InvoiceLineType",
|
|
147
|
+
"InvoiceStatus",
|
|
130
148
|
"Job",
|
|
131
149
|
"JobOutput",
|
|
132
150
|
"JobPriority",
|
|
@@ -146,9 +164,8 @@ __all__ = [
|
|
|
146
164
|
"PresetVariant",
|
|
147
165
|
"RateLimitError",
|
|
148
166
|
"Resolution",
|
|
149
|
-
"SDK_VERSION",
|
|
150
|
-
"SIGNATURE_HEADER",
|
|
151
167
|
"StreamingConfig",
|
|
168
|
+
"SubtitleResult",
|
|
152
169
|
"SubtitleTrack",
|
|
153
170
|
"ThumbnailResult",
|
|
154
171
|
"ThumbnailSpec",
|
|
@@ -156,20 +173,23 @@ __all__ = [
|
|
|
156
173
|
"TranscodelyError",
|
|
157
174
|
"User",
|
|
158
175
|
"UserWithOrganizations",
|
|
159
|
-
"verify_signature",
|
|
160
176
|
"Video",
|
|
161
177
|
"VideoCodec",
|
|
162
178
|
"VideoRendition",
|
|
179
|
+
"VideoTextTrack",
|
|
163
180
|
"VideoVariant",
|
|
164
181
|
"WatchEventType",
|
|
165
182
|
"WatchJobResponse",
|
|
166
183
|
"WatchVideoResponse",
|
|
184
|
+
"WatermarkConfig",
|
|
167
185
|
"WebhookDelivery",
|
|
168
186
|
"WebhookEndpoint",
|
|
169
187
|
"WebhookError",
|
|
170
188
|
"WebhookEvent",
|
|
171
189
|
"WebhookPayloadError",
|
|
172
|
-
"Webhooks",
|
|
173
190
|
"WebhookSignatureError",
|
|
174
191
|
"WebhookTimestampError",
|
|
192
|
+
"Webhooks",
|
|
193
|
+
"construct_event",
|
|
194
|
+
"verify_signature",
|
|
175
195
|
]
|
|
@@ -132,9 +132,8 @@ def _transform_field(
|
|
|
132
132
|
parent[key] = _apply_enum(v, field.enum_type, mode)
|
|
133
133
|
return
|
|
134
134
|
|
|
135
|
-
if field.type == FieldDescriptor.TYPE_MESSAGE:
|
|
136
|
-
|
|
137
|
-
transform_enums_in_dict(v, field.message_type, mode=mode)
|
|
135
|
+
if field.type == FieldDescriptor.TYPE_MESSAGE and isinstance(v, dict):
|
|
136
|
+
transform_enums_in_dict(v, field.message_type, mode=mode)
|
|
138
137
|
|
|
139
138
|
|
|
140
139
|
def _apply_enum(value: str, enum_desc: EnumDescriptor, mode: str) -> str:
|
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
import json
|
|
6
|
-
from
|
|
6
|
+
from collections.abc import Mapping
|
|
7
|
+
from typing import Any
|
|
7
8
|
|
|
8
9
|
from ..errors import (
|
|
9
10
|
APIConnectionError,
|
|
@@ -55,7 +56,7 @@ def map_http_error(*, status: int, body: Any, headers: Mapping[str, str]) -> Tra
|
|
|
55
56
|
return InvalidRequestError(message, **kwargs)
|
|
56
57
|
if status == 429:
|
|
57
58
|
retry_after = _header(headers, "retry-after")
|
|
58
|
-
retry_after_ms:
|
|
59
|
+
retry_after_ms: int | None = None
|
|
59
60
|
if retry_after:
|
|
60
61
|
try:
|
|
61
62
|
retry_after_ms = max(0, int(retry_after) * 1000)
|
|
@@ -69,7 +70,7 @@ def map_http_error(*, status: int, body: Any, headers: Mapping[str, str]) -> Tra
|
|
|
69
70
|
return APIError(message, **kwargs)
|
|
70
71
|
|
|
71
72
|
|
|
72
|
-
def connection_error(cause: BaseException, message:
|
|
73
|
+
def connection_error(cause: BaseException, message: str | None = None) -> APIConnectionError:
|
|
73
74
|
msg = message or str(cause) or "network request failed"
|
|
74
75
|
err = APIConnectionError(msg)
|
|
75
76
|
err.__cause__ = cause
|
|
@@ -82,21 +83,21 @@ def _parse_body(body: Any) -> dict[str, Any]:
|
|
|
82
83
|
if isinstance(body, (bytes, bytearray)):
|
|
83
84
|
try:
|
|
84
85
|
return json.loads(body.decode("utf-8"))
|
|
85
|
-
except
|
|
86
|
+
except (UnicodeDecodeError, json.JSONDecodeError):
|
|
86
87
|
return {"message": body.decode("utf-8", errors="replace")}
|
|
87
88
|
if isinstance(body, str):
|
|
88
89
|
try:
|
|
89
90
|
return json.loads(body)
|
|
90
|
-
except
|
|
91
|
+
except json.JSONDecodeError:
|
|
91
92
|
return {"message": body}
|
|
92
93
|
return {}
|
|
93
94
|
|
|
94
95
|
|
|
95
|
-
def _header(headers: Mapping[str, str], key: str) ->
|
|
96
|
+
def _header(headers: Mapping[str, str], key: str) -> str | None:
|
|
96
97
|
# httpx headers are case-insensitive but expose .get with the lowercase form.
|
|
97
98
|
if hasattr(headers, "get"):
|
|
98
99
|
try:
|
|
99
100
|
return headers.get(key) or headers.get(key.title())
|
|
100
|
-
except Exception:
|
|
101
|
+
except Exception: # noqa: BLE001 — headers may be any duck-typed mapping
|
|
101
102
|
return None
|
|
102
103
|
return None
|
|
@@ -19,11 +19,13 @@ from __future__ import annotations
|
|
|
19
19
|
import random
|
|
20
20
|
import struct
|
|
21
21
|
import time
|
|
22
|
+
from collections.abc import Callable, Generator, Iterable
|
|
22
23
|
from dataclasses import dataclass, field
|
|
23
|
-
from typing import Any,
|
|
24
|
+
from typing import Any, TypeVar
|
|
24
25
|
|
|
25
26
|
import httpx
|
|
26
27
|
from google.protobuf.message import Message
|
|
28
|
+
from typing_extensions import Self
|
|
27
29
|
|
|
28
30
|
from .._codec import json_codec
|
|
29
31
|
from ..errors import RateLimitError, TranscodelyError
|
|
@@ -39,10 +41,10 @@ TRes = TypeVar("TRes", bound=Message)
|
|
|
39
41
|
class CallOptions:
|
|
40
42
|
"""Per-call overrides for timeout, retries, idempotency, headers."""
|
|
41
43
|
|
|
42
|
-
timeout:
|
|
43
|
-
max_retries:
|
|
44
|
-
idempotency_key:
|
|
45
|
-
api_version:
|
|
44
|
+
timeout: float | None = None
|
|
45
|
+
max_retries: int | None = None
|
|
46
|
+
idempotency_key: str | None = None
|
|
47
|
+
api_version: str | None = None
|
|
46
48
|
headers: dict[str, str] = field(default_factory=dict)
|
|
47
49
|
|
|
48
50
|
|
|
@@ -52,9 +54,9 @@ class LogEvent:
|
|
|
52
54
|
method: str
|
|
53
55
|
duration_ms: int
|
|
54
56
|
attempt: int
|
|
55
|
-
status:
|
|
56
|
-
request_id:
|
|
57
|
-
error:
|
|
57
|
+
status: int | None = None
|
|
58
|
+
request_id: str | None = None
|
|
59
|
+
error: TranscodelyError | None = None
|
|
58
60
|
|
|
59
61
|
|
|
60
62
|
_WRITE_PREFIXES = ("Create", "Update", "Delete", "Cancel", "Confirm", "Revoke", "Archive", "Enable")
|
|
@@ -71,17 +73,19 @@ class Transport:
|
|
|
71
73
|
self,
|
|
72
74
|
api_key: str,
|
|
73
75
|
*,
|
|
74
|
-
|
|
76
|
+
organization_id: str | None = None,
|
|
77
|
+
base_url: str | None = None,
|
|
75
78
|
timeout: float = 30.0,
|
|
76
79
|
max_retries: int = 3,
|
|
77
|
-
api_version:
|
|
78
|
-
default_headers:
|
|
79
|
-
http_client:
|
|
80
|
-
logger:
|
|
80
|
+
api_version: str | None = None,
|
|
81
|
+
default_headers: dict[str, str] | None = None,
|
|
82
|
+
http_client: httpx.Client | None = None,
|
|
83
|
+
logger: Callable[[LogEvent], None] | None = None,
|
|
81
84
|
) -> None:
|
|
82
85
|
if not api_key:
|
|
83
86
|
raise ValueError("Transcodely: api_key is required")
|
|
84
87
|
self.api_key = api_key
|
|
88
|
+
self.organization_id = organization_id
|
|
85
89
|
self.base_url = (base_url or DEFAULT_BASE_URL).rstrip("/")
|
|
86
90
|
self.timeout = timeout
|
|
87
91
|
self.max_retries = max_retries
|
|
@@ -91,16 +95,16 @@ class Transport:
|
|
|
91
95
|
self._owns_client = http_client is None
|
|
92
96
|
self._logger = logger
|
|
93
97
|
#: ID of the most recent successful or failed request, Stripe-style.
|
|
94
|
-
self.last_request_id:
|
|
98
|
+
self.last_request_id: str | None = None
|
|
95
99
|
|
|
96
100
|
def close(self) -> None:
|
|
97
101
|
if self._owns_client:
|
|
98
102
|
self._client.close()
|
|
99
103
|
|
|
100
|
-
def __enter__(self) ->
|
|
104
|
+
def __enter__(self) -> Self:
|
|
101
105
|
return self
|
|
102
106
|
|
|
103
|
-
def __exit__(self, *args:
|
|
107
|
+
def __exit__(self, *args: object) -> None:
|
|
104
108
|
self.close()
|
|
105
109
|
|
|
106
110
|
# ---------- unary ----------
|
|
@@ -111,7 +115,7 @@ class Transport:
|
|
|
111
115
|
method_name: str,
|
|
112
116
|
request: Message,
|
|
113
117
|
response: TRes,
|
|
114
|
-
opts:
|
|
118
|
+
opts: CallOptions | None = None,
|
|
115
119
|
) -> TRes:
|
|
116
120
|
opts = opts or CallOptions()
|
|
117
121
|
url = f"{self.base_url}/{service_name}/{method_name}"
|
|
@@ -161,7 +165,7 @@ class Transport:
|
|
|
161
165
|
method_name: str,
|
|
162
166
|
request: Message,
|
|
163
167
|
response_factory: Callable[[], TRes],
|
|
164
|
-
opts:
|
|
168
|
+
opts: CallOptions | None = None,
|
|
165
169
|
) -> Generator[TRes, None, None]:
|
|
166
170
|
opts = opts or CallOptions()
|
|
167
171
|
url = f"{self.base_url}/{service_name}/{method_name}"
|
|
@@ -207,7 +211,7 @@ class Transport:
|
|
|
207
211
|
self,
|
|
208
212
|
opts: CallOptions,
|
|
209
213
|
content_type: str,
|
|
210
|
-
idempotency_key:
|
|
214
|
+
idempotency_key: str | None = None,
|
|
211
215
|
) -> dict[str, str]:
|
|
212
216
|
headers = {
|
|
213
217
|
"content-type": content_type,
|
|
@@ -218,6 +222,8 @@ class Transport:
|
|
|
218
222
|
"transcodely-version": opts.api_version or self.api_version,
|
|
219
223
|
"accept": content_type,
|
|
220
224
|
}
|
|
225
|
+
if self.organization_id:
|
|
226
|
+
headers["x-organization-id"] = self.organization_id
|
|
221
227
|
if idempotency_key:
|
|
222
228
|
headers["idempotency-key"] = idempotency_key
|
|
223
229
|
headers.update(self.default_headers)
|
|
@@ -228,7 +234,7 @@ class Transport:
|
|
|
228
234
|
if self._logger is not None:
|
|
229
235
|
try:
|
|
230
236
|
self._logger(event)
|
|
231
|
-
except Exception:
|
|
237
|
+
except Exception: # noqa: BLE001, S110 — a caller's logger must never break requests
|
|
232
238
|
pass
|
|
233
239
|
|
|
234
240
|
def _with_retry(
|
|
@@ -239,7 +245,7 @@ class Transport:
|
|
|
239
245
|
fn: Callable[[int], TRes],
|
|
240
246
|
) -> TRes:
|
|
241
247
|
max_retries = opts.max_retries if opts.max_retries is not None else self.max_retries
|
|
242
|
-
last_error:
|
|
248
|
+
last_error: BaseException | None = None
|
|
243
249
|
for attempt in range(1, max_retries + 2):
|
|
244
250
|
try:
|
|
245
251
|
return fn(attempt)
|
|
@@ -260,9 +266,7 @@ def _is_retryable(exc: BaseException) -> bool:
|
|
|
260
266
|
return True
|
|
261
267
|
if exc.__class__.__name__ == "APIConnectionError":
|
|
262
268
|
return True
|
|
263
|
-
|
|
264
|
-
return True
|
|
265
|
-
return False
|
|
269
|
+
return exc.http_status is not None and exc.http_status >= 500
|
|
266
270
|
|
|
267
271
|
|
|
268
272
|
def _sleep_backoff(attempt: int, exc: BaseException) -> None:
|
|
@@ -308,5 +312,5 @@ def _try_json(data: bytes) -> Any:
|
|
|
308
312
|
import json
|
|
309
313
|
|
|
310
314
|
return json.loads(data.decode("utf-8"))
|
|
311
|
-
except
|
|
315
|
+
except (UnicodeDecodeError, json.JSONDecodeError):
|
|
312
316
|
return data.decode("utf-8", errors="replace")
|