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.
Files changed (97) hide show
  1. {transcodely-0.3.1 → transcodely-0.3.3}/PKG-INFO +87 -4
  2. {transcodely-0.3.1 → transcodely-0.3.3}/README.md +85 -2
  3. {transcodely-0.3.1 → transcodely-0.3.3}/pyproject.toml +2 -2
  4. {transcodely-0.3.1 → transcodely-0.3.3}/src/transcodely/__init__.py +41 -21
  5. {transcodely-0.3.1 → transcodely-0.3.3}/src/transcodely/_codec/json_codec.py +2 -3
  6. {transcodely-0.3.1 → transcodely-0.3.3}/src/transcodely/_transport/error_mapping.py +8 -7
  7. {transcodely-0.3.1 → transcodely-0.3.3}/src/transcodely/_transport/transport.py +29 -25
  8. {transcodely-0.3.1 → transcodely-0.3.3}/src/transcodely/client.py +38 -21
  9. {transcodely-0.3.1 → transcodely-0.3.3}/src/transcodely/errors.py +12 -12
  10. {transcodely-0.3.1 → transcodely-0.3.3}/src/transcodely/pagination.py +8 -8
  11. {transcodely-0.3.1 → transcodely-0.3.3}/src/transcodely/resources/_helpers.py +6 -5
  12. {transcodely-0.3.1 → transcodely-0.3.3}/src/transcodely/resources/api_keys.py +6 -6
  13. transcodely-0.3.3/src/transcodely/resources/apps.py +116 -0
  14. transcodely-0.3.3/src/transcodely/resources/billing.py +90 -0
  15. {transcodely-0.3.1 → transcodely-0.3.3}/src/transcodely/resources/events.py +10 -10
  16. {transcodely-0.3.1 → transcodely-0.3.3}/src/transcodely/resources/health.py +1 -3
  17. {transcodely-0.3.1 → transcodely-0.3.3}/src/transcodely/resources/jobs.py +57 -29
  18. {transcodely-0.3.1 → transcodely-0.3.3}/src/transcodely/resources/memberships.py +9 -8
  19. {transcodely-0.3.1 → transcodely-0.3.3}/src/transcodely/resources/organizations.py +6 -6
  20. {transcodely-0.3.1 → transcodely-0.3.3}/src/transcodely/resources/origins.py +6 -6
  21. {transcodely-0.3.1 → transcodely-0.3.3}/src/transcodely/resources/presets.py +7 -7
  22. {transcodely-0.3.1 → transcodely-0.3.3}/src/transcodely/resources/users.py +9 -8
  23. {transcodely-0.3.1 → transcodely-0.3.3}/src/transcodely/resources/videos.py +48 -8
  24. {transcodely-0.3.1 → transcodely-0.3.3}/src/transcodely/resources/webhook_endpoints.py +32 -31
  25. {transcodely-0.3.1 → transcodely-0.3.3}/src/transcodely/streaming.py +3 -2
  26. {transcodely-0.3.1 → transcodely-0.3.3}/src/transcodely/types.py +260 -182
  27. transcodely-0.3.3/src/transcodely/v1/app_pb2.py +111 -0
  28. {transcodely-0.3.1 → transcodely-0.3.3}/src/transcodely/v1/app_pb2.pyi +48 -4
  29. transcodely-0.3.3/src/transcodely/v1/billing_pb2.py +65 -0
  30. transcodely-0.3.3/src/transcodely/v1/billing_pb2.pyi +131 -0
  31. transcodely-0.3.3/src/transcodely/v1/job_pb2.py +226 -0
  32. {transcodely-0.3.1 → transcodely-0.3.3}/src/transcodely/v1/job_pb2.pyi +74 -10
  33. {transcodely-0.3.1 → transcodely-0.3.3}/src/transcodely/v1/subtitles_pb2.py +13 -7
  34. {transcodely-0.3.1 → transcodely-0.3.3}/src/transcodely/v1/subtitles_pb2.pyi +60 -3
  35. {transcodely-0.3.1 → transcodely-0.3.3}/src/transcodely/v1/thumbnails_pb2.py +14 -8
  36. {transcodely-0.3.1 → transcodely-0.3.3}/src/transcodely/v1/thumbnails_pb2.pyi +14 -4
  37. transcodely-0.3.3/src/transcodely/v1/video_pb2.py +210 -0
  38. {transcodely-0.3.1 → transcodely-0.3.3}/src/transcodely/v1/video_pb2.pyi +168 -10
  39. transcodely-0.3.3/src/transcodely/v1/watermark_pb2.py +57 -0
  40. transcodely-0.3.3/src/transcodely/v1/watermark_pb2.pyi +56 -0
  41. transcodely-0.3.3/src/transcodely/v1/webhook_pb2.py +155 -0
  42. {transcodely-0.3.1 → transcodely-0.3.3}/src/transcodely/v1/webhook_pb2.pyi +22 -6
  43. {transcodely-0.3.1 → transcodely-0.3.3}/src/transcodely/version.py +1 -1
  44. {transcodely-0.3.1 → transcodely-0.3.3}/src/transcodely/webhooks/construct_event.py +5 -4
  45. {transcodely-0.3.1 → transcodely-0.3.3}/src/transcodely/webhooks/signature.py +6 -6
  46. {transcodely-0.3.1 → transcodely-0.3.3}/src/transcodely/webhooks/types.py +34 -9
  47. transcodely-0.3.1/src/transcodely/resources/apps.py +0 -71
  48. transcodely-0.3.1/src/transcodely/v1/app_pb2.py +0 -97
  49. transcodely-0.3.1/src/transcodely/v1/job_pb2.py +0 -205
  50. transcodely-0.3.1/src/transcodely/v1/video_pb2.py +0 -168
  51. transcodely-0.3.1/src/transcodely/v1/webhook_pb2.py +0 -151
  52. {transcodely-0.3.1 → transcodely-0.3.3}/.gitignore +0 -0
  53. {transcodely-0.3.1 → transcodely-0.3.3}/LICENSE +0 -0
  54. {transcodely-0.3.1 → transcodely-0.3.3}/src/buf/__init__.py +0 -0
  55. {transcodely-0.3.1 → transcodely-0.3.3}/src/buf/validate/__init__.py +0 -0
  56. {transcodely-0.3.1 → transcodely-0.3.3}/src/buf/validate/validate_pb2.py +0 -0
  57. {transcodely-0.3.1 → transcodely-0.3.3}/src/buf/validate/validate_pb2.pyi +0 -0
  58. {transcodely-0.3.1 → transcodely-0.3.3}/src/transcodely/_codec/__init__.py +0 -0
  59. {transcodely-0.3.1 → transcodely-0.3.3}/src/transcodely/_transport/__init__.py +0 -0
  60. {transcodely-0.3.1 → transcodely-0.3.3}/src/transcodely/_transport/headers.py +0 -0
  61. {transcodely-0.3.1 → transcodely-0.3.3}/src/transcodely/py.typed +0 -0
  62. {transcodely-0.3.1 → transcodely-0.3.3}/src/transcodely/resources/__init__.py +0 -0
  63. {transcodely-0.3.1 → transcodely-0.3.3}/src/transcodely/v1/api_key_pb2.py +0 -0
  64. {transcodely-0.3.1 → transcodely-0.3.3}/src/transcodely/v1/api_key_pb2.pyi +0 -0
  65. {transcodely-0.3.1 → transcodely-0.3.3}/src/transcodely/v1/codec_av1_pb2.py +0 -0
  66. {transcodely-0.3.1 → transcodely-0.3.3}/src/transcodely/v1/codec_av1_pb2.pyi +0 -0
  67. {transcodely-0.3.1 → transcodely-0.3.3}/src/transcodely/v1/codec_h264_pb2.py +0 -0
  68. {transcodely-0.3.1 → transcodely-0.3.3}/src/transcodely/v1/codec_h264_pb2.pyi +0 -0
  69. {transcodely-0.3.1 → transcodely-0.3.3}/src/transcodely/v1/codec_h265_pb2.py +0 -0
  70. {transcodely-0.3.1 → transcodely-0.3.3}/src/transcodely/v1/codec_h265_pb2.pyi +0 -0
  71. {transcodely-0.3.1 → transcodely-0.3.3}/src/transcodely/v1/codec_vp9_pb2.py +0 -0
  72. {transcodely-0.3.1 → transcodely-0.3.3}/src/transcodely/v1/codec_vp9_pb2.pyi +0 -0
  73. {transcodely-0.3.1 → transcodely-0.3.3}/src/transcodely/v1/common_pb2.py +0 -0
  74. {transcodely-0.3.1 → transcodely-0.3.3}/src/transcodely/v1/common_pb2.pyi +0 -0
  75. {transcodely-0.3.1 → transcodely-0.3.3}/src/transcodely/v1/content_aware_pb2.py +0 -0
  76. {transcodely-0.3.1 → transcodely-0.3.3}/src/transcodely/v1/content_aware_pb2.pyi +0 -0
  77. {transcodely-0.3.1 → transcodely-0.3.3}/src/transcodely/v1/drm_pb2.py +0 -0
  78. {transcodely-0.3.1 → transcodely-0.3.3}/src/transcodely/v1/drm_pb2.pyi +0 -0
  79. {transcodely-0.3.1 → transcodely-0.3.3}/src/transcodely/v1/hdr_pb2.py +0 -0
  80. {transcodely-0.3.1 → transcodely-0.3.3}/src/transcodely/v1/hdr_pb2.pyi +0 -0
  81. {transcodely-0.3.1 → transcodely-0.3.3}/src/transcodely/v1/health_pb2.py +0 -0
  82. {transcodely-0.3.1 → transcodely-0.3.3}/src/transcodely/v1/health_pb2.pyi +0 -0
  83. {transcodely-0.3.1 → transcodely-0.3.3}/src/transcodely/v1/media_pb2.py +0 -0
  84. {transcodely-0.3.1 → transcodely-0.3.3}/src/transcodely/v1/media_pb2.pyi +0 -0
  85. {transcodely-0.3.1 → transcodely-0.3.3}/src/transcodely/v1/membership_pb2.py +0 -0
  86. {transcodely-0.3.1 → transcodely-0.3.3}/src/transcodely/v1/membership_pb2.pyi +0 -0
  87. {transcodely-0.3.1 → transcodely-0.3.3}/src/transcodely/v1/organization_pb2.py +0 -0
  88. {transcodely-0.3.1 → transcodely-0.3.3}/src/transcodely/v1/organization_pb2.pyi +0 -0
  89. {transcodely-0.3.1 → transcodely-0.3.3}/src/transcodely/v1/origin_pb2.py +0 -0
  90. {transcodely-0.3.1 → transcodely-0.3.3}/src/transcodely/v1/origin_pb2.pyi +0 -0
  91. {transcodely-0.3.1 → transcodely-0.3.3}/src/transcodely/v1/preset_pb2.py +0 -0
  92. {transcodely-0.3.1 → transcodely-0.3.3}/src/transcodely/v1/preset_pb2.pyi +0 -0
  93. {transcodely-0.3.1 → transcodely-0.3.3}/src/transcodely/v1/streaming_pb2.py +0 -0
  94. {transcodely-0.3.1 → transcodely-0.3.3}/src/transcodely/v1/streaming_pb2.pyi +0 -0
  95. {transcodely-0.3.1 → transcodely-0.3.3}/src/transcodely/v1/user_pb2.py +0 -0
  96. {transcodely-0.3.1 → transcodely-0.3.3}/src/transcodely/v1/user_pb2.pyi +0 -0
  97. {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.1
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.5; extra == 'dev'
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 13 event types are `job.created`, `job.succeeded`, `job.failed`, `job.canceled`, `job.progress`, `output.created`, `output.ready`, `output.failed`, `output.progress`, `video.uploaded`, `video.deleted`, `app.created`, and `app.updated`. 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`.
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 13 event types are `job.created`, `job.succeeded`, `job.failed`, `job.canceled`, `job.progress`, `output.created`, `output.ready`, `output.failed`, `output.progress`, `video.uploaded`, `video.deleted`, `app.created`, and `app.updated`. 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`.
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.1"
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.5",
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
- if isinstance(v, dict):
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 typing import Any, Mapping, Optional
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: Optional[int] = None
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: Optional[str] = None) -> APIConnectionError:
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 Exception:
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 Exception:
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) -> Optional[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, Callable, Generator, Iterable, Optional, TypeVar
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: Optional[float] = None
43
- max_retries: Optional[int] = None
44
- idempotency_key: Optional[str] = None
45
- api_version: Optional[str] = None
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: Optional[int] = None
56
- request_id: Optional[str] = None
57
- error: Optional[TranscodelyError] = None
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
- base_url: Optional[str] = None,
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: Optional[str] = None,
78
- default_headers: Optional[dict[str, str]] = None,
79
- http_client: Optional[httpx.Client] = None,
80
- logger: Optional[Callable[[LogEvent], None]] = None,
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: Optional[str] = None
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) -> "Transport":
104
+ def __enter__(self) -> Self:
101
105
  return self
102
106
 
103
- def __exit__(self, *args: Any) -> None:
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: Optional[CallOptions] = None,
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: Optional[CallOptions] = None,
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: Optional[str] = None,
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: Optional[BaseException] = None
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
- if exc.http_status is not None and exc.http_status >= 500:
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 Exception:
315
+ except (UnicodeDecodeError, json.JSONDecodeError):
312
316
  return data.decode("utf-8", errors="replace")