convertintomp4 1.0.0__py3-none-any.whl

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.
@@ -0,0 +1,891 @@
1
+ """ConvertIntoMP4 API client."""
2
+
3
+ import time
4
+ from typing import Any, BinaryIO, Dict, List, Optional, Union
5
+ from urllib.parse import urlencode
6
+
7
+ import requests
8
+
9
+ from .exceptions import (
10
+ ApiError,
11
+ AuthenticationError,
12
+ ConvertIntoMP4Error,
13
+ NotFoundError,
14
+ RateLimitError,
15
+ TimeoutError,
16
+ ValidationError,
17
+ )
18
+ from .types import (
19
+ Format,
20
+ FormatOption,
21
+ Job,
22
+ PdfOperationResult,
23
+ PdfPermissions,
24
+ WaitOptions,
25
+ Webhook,
26
+ )
27
+
28
+ DEFAULT_BASE_URL = "https://api.convertintomp4.com/v1"
29
+ SANDBOX_BASE_URL = "https://sandbox.convertintomp4.com/v1"
30
+
31
+
32
+ class Client:
33
+ """ConvertIntoMP4 API client.
34
+
35
+ Args:
36
+ api_key: Your API key.
37
+ base_url: Base URL for the API. Defaults to production.
38
+ sandbox: If True, use the sandbox environment.
39
+ timeout: Request timeout in seconds. Defaults to 30.
40
+ headers: Additional headers to include in requests.
41
+
42
+ Example::
43
+
44
+ from convertintomp4 import Client
45
+
46
+ client = Client(api_key="your-api-key")
47
+ job = client.create_job({
48
+ "tasks": {
49
+ "import": {"operation": "import/url", "url": "https://example.com/video.mp4"},
50
+ "convert": {"operation": "convert", "input": "import", "output_format": "webm"},
51
+ "export": {"operation": "export/url", "input": "convert"},
52
+ }
53
+ })
54
+ """
55
+
56
+ def __init__(
57
+ self,
58
+ api_key: str,
59
+ base_url: Optional[str] = None,
60
+ sandbox: bool = False,
61
+ timeout: int = 30,
62
+ headers: Optional[Dict[str, str]] = None,
63
+ ) -> None:
64
+ if not api_key:
65
+ raise ConvertIntoMP4Error("API key is required")
66
+
67
+ self.api_key = api_key
68
+ self.base_url = (
69
+ SANDBOX_BASE_URL if sandbox else (base_url or DEFAULT_BASE_URL)
70
+ )
71
+ self.timeout = timeout
72
+ self._session = requests.Session()
73
+ self._session.headers.update(
74
+ {
75
+ "Authorization": f"Bearer {api_key}",
76
+ "Content-Type": "application/json",
77
+ "User-Agent": "convertintomp4-python/1.0.0",
78
+ }
79
+ )
80
+ if headers:
81
+ self._session.headers.update(headers)
82
+
83
+ def close(self) -> None:
84
+ """Close the HTTP session."""
85
+ self._session.close()
86
+
87
+ def __enter__(self) -> "Client":
88
+ return self
89
+
90
+ def __exit__(self, *args: Any) -> None:
91
+ self.close()
92
+
93
+ # =========================================================================
94
+ # HTTP Methods
95
+ # =========================================================================
96
+
97
+ def _request(
98
+ self,
99
+ method: str,
100
+ path: str,
101
+ json: Optional[Dict[str, Any]] = None,
102
+ params: Optional[Dict[str, Any]] = None,
103
+ ) -> Any:
104
+ """Make an HTTP request to the API."""
105
+ url = f"{self.base_url}{path}"
106
+
107
+ try:
108
+ response = self._session.request(
109
+ method=method,
110
+ url=url,
111
+ json=json,
112
+ params=params,
113
+ timeout=self.timeout,
114
+ )
115
+ except requests.exceptions.Timeout:
116
+ raise TimeoutError(f"Request to {path} timed out after {self.timeout}s")
117
+ except requests.exceptions.ConnectionError as e:
118
+ raise ConvertIntoMP4Error(f"Connection error: {e}")
119
+ except requests.exceptions.RequestException as e:
120
+ raise ConvertIntoMP4Error(f"Request failed: {e}")
121
+
122
+ return self._handle_response(response)
123
+
124
+ def _handle_response(self, response: requests.Response) -> Any:
125
+ """Handle the API response and raise appropriate exceptions."""
126
+ if response.status_code == 204:
127
+ return None
128
+
129
+ try:
130
+ data = response.json()
131
+ except ValueError:
132
+ if response.ok:
133
+ return response.content
134
+ raise ApiError(
135
+ response.status_code,
136
+ "PARSE_ERROR",
137
+ "Failed to parse response as JSON",
138
+ )
139
+
140
+ if not response.ok:
141
+ error = data.get("error", {})
142
+ code = error.get("code", "UNKNOWN_ERROR")
143
+ message = error.get("message", "An unknown error occurred")
144
+ details = error.get("details")
145
+
146
+ if response.status_code == 401:
147
+ raise AuthenticationError(message)
148
+ if response.status_code == 404:
149
+ raise NotFoundError(message=message)
150
+ if response.status_code == 429:
151
+ retry_after = response.headers.get("Retry-After")
152
+ raise RateLimitError(
153
+ retry_after=int(retry_after) if retry_after else None
154
+ )
155
+ if response.status_code == 400:
156
+ raise ValidationError(message, details)
157
+
158
+ raise ApiError(response.status_code, code, message, details)
159
+
160
+ return data.get("data", data)
161
+
162
+ def _upload(
163
+ self,
164
+ path: str,
165
+ file: Union[BinaryIO, bytes],
166
+ fields: Dict[str, str],
167
+ filename: Optional[str] = None,
168
+ ) -> Any:
169
+ """Upload a file via multipart form data."""
170
+ url = f"{self.base_url}{path}"
171
+
172
+ if isinstance(file, bytes):
173
+ files = {"file": (filename or "upload", file)}
174
+ else:
175
+ files = {"file": (filename or getattr(file, "name", "upload"), file)}
176
+
177
+ try:
178
+ # Remove Content-Type header for multipart upload (requests sets it)
179
+ headers = dict(self._session.headers)
180
+ headers.pop("Content-Type", None)
181
+
182
+ response = requests.post(
183
+ url,
184
+ files=files,
185
+ data=fields,
186
+ headers=headers,
187
+ timeout=self.timeout,
188
+ )
189
+ except requests.exceptions.Timeout:
190
+ raise TimeoutError(f"Upload to {path} timed out after {self.timeout}s")
191
+ except requests.exceptions.RequestException as e:
192
+ raise ConvertIntoMP4Error(f"Upload failed: {e}")
193
+
194
+ return self._handle_response(response)
195
+
196
+ # =========================================================================
197
+ # Job Methods
198
+ # =========================================================================
199
+
200
+ def create_job(self, request: Dict[str, Any]) -> Job:
201
+ """Create a new conversion job with one or more tasks.
202
+
203
+ Args:
204
+ request: Job configuration with tasks, optional tag and webhook_url.
205
+
206
+ Returns:
207
+ The created Job.
208
+
209
+ Example::
210
+
211
+ job = client.create_job({
212
+ "tasks": {
213
+ "import": {"operation": "import/url", "url": "https://example.com/video.mp4"},
214
+ "convert": {"operation": "convert", "input": "import", "output_format": "webm"},
215
+ "export": {"operation": "export/url", "input": "convert"},
216
+ }
217
+ })
218
+ """
219
+ data = self._request("POST", "/jobs", json=request)
220
+ return Job.from_dict(data)
221
+
222
+ def get_job(self, job_id: str) -> Job:
223
+ """Get the details and status of a specific job.
224
+
225
+ Args:
226
+ job_id: The job identifier.
227
+
228
+ Returns:
229
+ The Job with current status.
230
+ """
231
+ data = self._request("GET", f"/jobs/{job_id}")
232
+ return Job.from_dict(data)
233
+
234
+ def list_jobs(
235
+ self,
236
+ status: Optional[str] = None,
237
+ tag: Optional[str] = None,
238
+ limit: int = 20,
239
+ offset: int = 0,
240
+ ) -> Dict[str, Any]:
241
+ """List all jobs, optionally filtered.
242
+
243
+ Args:
244
+ status: Filter by job status.
245
+ tag: Filter by tag.
246
+ limit: Maximum number of results. Defaults to 20.
247
+ offset: Offset for pagination. Defaults to 0.
248
+
249
+ Returns:
250
+ Dict with 'data' (list of Jobs) and 'meta' (pagination info).
251
+ """
252
+ params: Dict[str, Any] = {"limit": limit, "offset": offset}
253
+ if status:
254
+ params["status"] = status
255
+ if tag:
256
+ params["tag"] = tag
257
+
258
+ result = self._request("GET", "/jobs", params=params)
259
+
260
+ if isinstance(result, dict):
261
+ jobs = [Job.from_dict(j) for j in result.get("data", result) if isinstance(j, dict)]
262
+ return {"data": jobs, "meta": result.get("meta", {})}
263
+
264
+ if isinstance(result, list):
265
+ return {"data": [Job.from_dict(j) for j in result], "meta": {}}
266
+
267
+ return {"data": [], "meta": {}}
268
+
269
+ def delete_job(self, job_id: str) -> None:
270
+ """Delete a job and its associated files.
271
+
272
+ Args:
273
+ job_id: The job identifier.
274
+ """
275
+ self._request("DELETE", f"/jobs/{job_id}")
276
+
277
+ def cancel_job(self, job_id: str) -> Job:
278
+ """Cancel a running job.
279
+
280
+ Args:
281
+ job_id: The job identifier.
282
+
283
+ Returns:
284
+ The cancelled Job.
285
+ """
286
+ data = self._request("POST", f"/jobs/{job_id}/cancel")
287
+ return Job.from_dict(data)
288
+
289
+ def retry_job(self, job_id: str) -> Job:
290
+ """Retry a failed job.
291
+
292
+ Args:
293
+ job_id: The job identifier.
294
+
295
+ Returns:
296
+ The retried Job.
297
+ """
298
+ data = self._request("POST", f"/jobs/{job_id}/retry")
299
+ return Job.from_dict(data)
300
+
301
+ def wait_for_job(
302
+ self,
303
+ job_id: str,
304
+ interval: float = 1.0,
305
+ timeout: float = 300.0,
306
+ ) -> Job:
307
+ """Poll a job until it reaches a terminal state.
308
+
309
+ Args:
310
+ job_id: The job identifier.
311
+ interval: Polling interval in seconds. Defaults to 1.0.
312
+ timeout: Maximum wait time in seconds. Defaults to 300 (5 minutes).
313
+
314
+ Returns:
315
+ The completed/failed/cancelled Job.
316
+
317
+ Raises:
318
+ TimeoutError: If the job does not complete within the timeout.
319
+ """
320
+ start_time = time.monotonic()
321
+
322
+ while time.monotonic() - start_time < timeout:
323
+ job = self.get_job(job_id)
324
+
325
+ if job.status in ("completed", "failed", "cancelled"):
326
+ return job
327
+
328
+ time.sleep(interval)
329
+
330
+ raise TimeoutError(
331
+ f"Job {job_id} did not complete within {timeout} seconds"
332
+ )
333
+
334
+ # =========================================================================
335
+ # Quick Convert Methods
336
+ # =========================================================================
337
+
338
+ def convert(
339
+ self,
340
+ file: Union[BinaryIO, bytes],
341
+ target_format: str,
342
+ filename: Optional[str] = None,
343
+ **options: Any,
344
+ ) -> Dict[str, Any]:
345
+ """Quick file conversion - upload and convert in one step.
346
+
347
+ Args:
348
+ file: File object or bytes to convert.
349
+ target_format: Target format (e.g., 'webm', 'mp3', 'png').
350
+ filename: Optional filename for the upload.
351
+ **options: Additional conversion options (quality, resolution, etc.).
352
+
353
+ Returns:
354
+ Dict with jobId, status, and statusUrl.
355
+ """
356
+ fields = {"targetFormat": target_format}
357
+ for key, value in options.items():
358
+ fields[key] = str(value)
359
+
360
+ return self._upload("/convert", file, fields, filename)
361
+
362
+ def upload_file(
363
+ self,
364
+ file: Union[BinaryIO, bytes],
365
+ target_format: str,
366
+ filename: Optional[str] = None,
367
+ **options: Any,
368
+ ) -> Dict[str, Any]:
369
+ """Upload a file for conversion.
370
+
371
+ Alias for `convert()`.
372
+ """
373
+ return self.convert(file, target_format, filename, **options)
374
+
375
+ def import_from_url(
376
+ self,
377
+ url: str,
378
+ target_format: str,
379
+ filename: Optional[str] = None,
380
+ **options: Any,
381
+ ) -> Dict[str, Any]:
382
+ """Import a file from a URL and start conversion.
383
+
384
+ Args:
385
+ url: URL of the file to import.
386
+ target_format: Target format for conversion.
387
+ filename: Optional filename override.
388
+ **options: Additional conversion options.
389
+
390
+ Returns:
391
+ Dict with jobId, status, and statusUrl.
392
+ """
393
+ body: Dict[str, Any] = {"url": url, "targetFormat": target_format}
394
+ if filename:
395
+ body["filename"] = filename
396
+ body.update(options)
397
+ return self._request("POST", "/import/url", json=body)
398
+
399
+ def download_file(self, job_id: str) -> bytes:
400
+ """Download a converted file by job ID.
401
+
402
+ Args:
403
+ job_id: The job identifier.
404
+
405
+ Returns:
406
+ The file contents as bytes.
407
+ """
408
+ url = f"{self.base_url}/download/{job_id}"
409
+ response = self._session.get(url, timeout=self.timeout)
410
+
411
+ if not response.ok:
412
+ self._handle_response(response)
413
+
414
+ return response.content
415
+
416
+ # =========================================================================
417
+ # Format Methods
418
+ # =========================================================================
419
+
420
+ def list_formats(self, category: Optional[str] = None) -> List[Format]:
421
+ """List all supported formats.
422
+
423
+ Args:
424
+ category: Optionally filter by category (video, audio, image, document).
425
+
426
+ Returns:
427
+ List of Format objects.
428
+ """
429
+ params = {}
430
+ if category:
431
+ params["category"] = category
432
+
433
+ data = self._request("GET", "/formats", params=params or None)
434
+
435
+ if isinstance(data, list):
436
+ return [Format.from_dict(f) for f in data]
437
+ return []
438
+
439
+ def get_format(self, format_id: str) -> Format:
440
+ """Get detailed info about a specific format.
441
+
442
+ Args:
443
+ format_id: The format identifier (e.g., 'mp4', 'webm').
444
+
445
+ Returns:
446
+ Format object.
447
+ """
448
+ data = self._request("GET", f"/formats/{format_id}")
449
+ return Format.from_dict(data)
450
+
451
+ def get_format_options(
452
+ self, input_format: str, output_format: str
453
+ ) -> Dict[str, Any]:
454
+ """Get available conversion options for a format pair.
455
+
456
+ Args:
457
+ input_format: Source format.
458
+ output_format: Target format.
459
+
460
+ Returns:
461
+ Dict with input_format, output_format, and list of options.
462
+ """
463
+ params = {
464
+ "input_format": input_format,
465
+ "output_format": output_format,
466
+ }
467
+ return self._request("GET", "/formats/options", params=params)
468
+
469
+ # =========================================================================
470
+ # Webhook Methods
471
+ # =========================================================================
472
+
473
+ def create_webhook(
474
+ self,
475
+ url: str,
476
+ events: List[str],
477
+ secret: Optional[str] = None,
478
+ metadata: Optional[Dict[str, str]] = None,
479
+ ) -> Webhook:
480
+ """Create a new webhook subscription.
481
+
482
+ Args:
483
+ url: The URL to receive webhook events.
484
+ events: List of event types to subscribe to.
485
+ secret: Optional secret for signing webhook payloads.
486
+ metadata: Optional metadata key-value pairs.
487
+
488
+ Returns:
489
+ The created Webhook.
490
+ """
491
+ body: Dict[str, Any] = {"url": url, "events": events}
492
+ if secret:
493
+ body["secret"] = secret
494
+ if metadata:
495
+ body["metadata"] = metadata
496
+
497
+ data = self._request("POST", "/webhooks", json=body)
498
+ return Webhook.from_dict(data)
499
+
500
+ def list_webhooks(self) -> List[Webhook]:
501
+ """List all registered webhooks.
502
+
503
+ Returns:
504
+ List of Webhook objects.
505
+ """
506
+ data = self._request("GET", "/webhooks")
507
+ if isinstance(data, list):
508
+ return [Webhook.from_dict(w) for w in data]
509
+ return []
510
+
511
+ def get_webhook(self, webhook_id: str) -> Webhook:
512
+ """Get a specific webhook by ID.
513
+
514
+ Args:
515
+ webhook_id: The webhook identifier.
516
+
517
+ Returns:
518
+ Webhook object.
519
+ """
520
+ data = self._request("GET", f"/webhooks/{webhook_id}")
521
+ return Webhook.from_dict(data)
522
+
523
+ def delete_webhook(self, webhook_id: str) -> None:
524
+ """Delete a webhook subscription.
525
+
526
+ Args:
527
+ webhook_id: The webhook identifier.
528
+ """
529
+ self._request("DELETE", f"/webhooks/{webhook_id}")
530
+
531
+ # =========================================================================
532
+ # PDF Operations
533
+ # =========================================================================
534
+
535
+ def pdf_ocr(
536
+ self, file: str, language: Optional[str] = None
537
+ ) -> PdfOperationResult:
538
+ """Perform OCR on a PDF file.
539
+
540
+ Args:
541
+ file: URL of the PDF file.
542
+ language: OCR language (e.g., 'eng', 'deu').
543
+
544
+ Returns:
545
+ PdfOperationResult with output URL and metadata.
546
+ """
547
+ body: Dict[str, Any] = {"file": file}
548
+ if language:
549
+ body["language"] = language
550
+ data = self._request("POST", "/pdf/ocr", json=body)
551
+ return PdfOperationResult.from_dict(data)
552
+
553
+ def pdf_split(self, file: str, ranges: List[str]) -> List[PdfOperationResult]:
554
+ """Split a PDF into multiple documents.
555
+
556
+ Args:
557
+ file: URL of the PDF file.
558
+ ranges: List of page ranges (e.g., ['1-3', '4-6']).
559
+
560
+ Returns:
561
+ List of PdfOperationResult, one per range.
562
+ """
563
+ body = {"file": file, "ranges": ranges}
564
+ data = self._request("POST", "/pdf/split", json=body)
565
+ if isinstance(data, list):
566
+ return [PdfOperationResult.from_dict(d) for d in data]
567
+ return [PdfOperationResult.from_dict(data)]
568
+
569
+ def pdf_merge(self, files: List[str]) -> PdfOperationResult:
570
+ """Merge multiple PDF files into one.
571
+
572
+ Args:
573
+ files: List of URLs of PDF files to merge.
574
+
575
+ Returns:
576
+ PdfOperationResult with the merged PDF URL.
577
+ """
578
+ body = {"files": files}
579
+ data = self._request("POST", "/pdf/merge", json=body)
580
+ return PdfOperationResult.from_dict(data)
581
+
582
+ def pdf_encrypt(
583
+ self,
584
+ file: str,
585
+ password: str,
586
+ permissions: Optional[PdfPermissions] = None,
587
+ ) -> PdfOperationResult:
588
+ """Encrypt a PDF with a password.
589
+
590
+ Args:
591
+ file: URL of the PDF file.
592
+ password: Password to set.
593
+ permissions: Optional permission restrictions.
594
+
595
+ Returns:
596
+ PdfOperationResult with the encrypted PDF URL.
597
+ """
598
+ body: Dict[str, Any] = {"file": file, "password": password}
599
+ if permissions:
600
+ body["permissions"] = dict(permissions)
601
+ data = self._request("POST", "/pdf/encrypt", json=body)
602
+ return PdfOperationResult.from_dict(data)
603
+
604
+ def pdf_decrypt(self, file: str, password: str) -> PdfOperationResult:
605
+ """Decrypt a password-protected PDF.
606
+
607
+ Args:
608
+ file: URL of the PDF file.
609
+ password: Password to unlock the PDF.
610
+
611
+ Returns:
612
+ PdfOperationResult with the decrypted PDF URL.
613
+ """
614
+ body = {"file": file, "password": password}
615
+ data = self._request("POST", "/pdf/decrypt", json=body)
616
+ return PdfOperationResult.from_dict(data)
617
+
618
+ def pdf_rotate(
619
+ self,
620
+ file: str,
621
+ angle: int,
622
+ pages: Optional[List[int]] = None,
623
+ ) -> PdfOperationResult:
624
+ """Rotate pages in a PDF.
625
+
626
+ Args:
627
+ file: URL of the PDF file.
628
+ angle: Rotation angle in degrees (90, 180, 270).
629
+ pages: Optional list of page numbers to rotate. Rotates all if omitted.
630
+
631
+ Returns:
632
+ PdfOperationResult with the rotated PDF URL.
633
+ """
634
+ body: Dict[str, Any] = {"file": file, "angle": angle}
635
+ if pages:
636
+ body["pages"] = pages
637
+ data = self._request("POST", "/pdf/rotate", json=body)
638
+ return PdfOperationResult.from_dict(data)
639
+
640
+ def pdf_compress(self, file: str) -> PdfOperationResult:
641
+ """Compress a PDF to reduce file size.
642
+
643
+ Args:
644
+ file: URL of the PDF file.
645
+
646
+ Returns:
647
+ PdfOperationResult with the compressed PDF URL.
648
+ """
649
+ body = {"file": file}
650
+ data = self._request("POST", "/pdf/compress", json=body)
651
+ return PdfOperationResult.from_dict(data)
652
+
653
+ def pdf_to_pdfa(self, file: str) -> PdfOperationResult:
654
+ """Convert a PDF to PDF/A archival format.
655
+
656
+ Args:
657
+ file: URL of the PDF file.
658
+
659
+ Returns:
660
+ PdfOperationResult with the PDF/A URL.
661
+ """
662
+ body = {"file": file}
663
+ data = self._request("POST", "/pdf/to-pdfa", json=body)
664
+ return PdfOperationResult.from_dict(data)
665
+
666
+ def pdf_extract_pages(
667
+ self, file: str, pages: List[int]
668
+ ) -> PdfOperationResult:
669
+ """Extract specific pages from a PDF.
670
+
671
+ Args:
672
+ file: URL of the PDF file.
673
+ pages: List of page numbers to extract.
674
+
675
+ Returns:
676
+ PdfOperationResult with the extracted pages PDF URL.
677
+ """
678
+ body = {"file": file, "pages": pages}
679
+ data = self._request("POST", "/pdf/extract-pages", json=body)
680
+ return PdfOperationResult.from_dict(data)
681
+
682
+ def pdf_watermark(
683
+ self,
684
+ file: str,
685
+ text: Optional[str] = None,
686
+ image: Optional[str] = None,
687
+ opacity: Optional[float] = None,
688
+ ) -> PdfOperationResult:
689
+ """Add a watermark to a PDF.
690
+
691
+ Args:
692
+ file: URL of the PDF file.
693
+ text: Watermark text. Either text or image must be provided.
694
+ image: URL of watermark image. Either text or image must be provided.
695
+ opacity: Watermark opacity (0.0 to 1.0).
696
+
697
+ Returns:
698
+ PdfOperationResult with the watermarked PDF URL.
699
+ """
700
+ body: Dict[str, Any] = {"file": file}
701
+ if text:
702
+ body["text"] = text
703
+ if image:
704
+ body["image"] = image
705
+ if opacity is not None:
706
+ body["opacity"] = opacity
707
+ data = self._request("POST", "/pdf/watermark", json=body)
708
+ return PdfOperationResult.from_dict(data)
709
+
710
+ # =========================================================================
711
+ # Presigned Upload Methods (Direct-to-R2)
712
+ # =========================================================================
713
+
714
+ def get_presigned_upload_url(
715
+ self,
716
+ filename: str,
717
+ content_type: str = "application/octet-stream",
718
+ file_size: Optional[int] = None,
719
+ ) -> Dict[str, Any]:
720
+ """Get a presigned URL for uploading a file directly to R2 storage.
721
+
722
+ This bypasses the API server for large file uploads.
723
+
724
+ Args:
725
+ filename: Name of the file being uploaded.
726
+ content_type: MIME type of the file.
727
+ file_size: Optional file size hint for quota checking.
728
+
729
+ Returns:
730
+ Dict with uploadUrl, storageKey, expiresIn, etc.
731
+
732
+ Example::
733
+
734
+ presign = client.get_presigned_upload_url("video.mp4", "video/mp4")
735
+ # Upload directly to R2
736
+ requests.put(presign["uploadUrl"], data=file_data)
737
+ # Start conversion
738
+ result = client.convert_from_storage(presign["storageKey"], "mp4", "webm")
739
+ """
740
+ body: Dict[str, Any] = {
741
+ "filename": filename,
742
+ "contentType": content_type,
743
+ }
744
+ if file_size is not None:
745
+ body["fileSize"] = file_size
746
+ return self._request("POST", "/uploads/presign", json=body)
747
+
748
+ def convert_from_storage(
749
+ self,
750
+ storage_key: str,
751
+ source_format: str,
752
+ target_format: str,
753
+ original_name: Optional[str] = None,
754
+ tool_operation: Optional[str] = None,
755
+ **options: Any,
756
+ ) -> Dict[str, Any]:
757
+ """Start a conversion from a file already uploaded to R2 storage.
758
+
759
+ Args:
760
+ storage_key: The R2 storage key from get_presigned_upload_url().
761
+ source_format: Source file format (e.g., "mp4").
762
+ target_format: Target conversion format (e.g., "webm").
763
+ original_name: Optional original filename.
764
+ tool_operation: Optional tool operation.
765
+ **options: Additional conversion options.
766
+
767
+ Returns:
768
+ Dict with jobId, status, statusUrl, realtimeUrl, sseUrl, etc.
769
+ """
770
+ body: Dict[str, Any] = {
771
+ "storageKey": storage_key,
772
+ "sourceFormat": source_format,
773
+ "targetFormat": target_format,
774
+ }
775
+ if original_name:
776
+ body["originalName"] = original_name
777
+ if tool_operation:
778
+ body["toolOperation"] = tool_operation
779
+ body.update(options)
780
+ return self._request("POST", "/convert/storage", json=body)
781
+
782
+ def upload_direct(
783
+ self,
784
+ file_data: Union[bytes, BinaryIO],
785
+ filename: str,
786
+ content_type: str,
787
+ target_format: str,
788
+ **options: Any,
789
+ ) -> Dict[str, Any]:
790
+ """Upload a file directly to R2 and start conversion in one step.
791
+
792
+ Combines get_presigned_upload_url + PUT upload + convert_from_storage.
793
+
794
+ Args:
795
+ file_data: File content as bytes or file-like object.
796
+ filename: Name of the file.
797
+ content_type: MIME type.
798
+ target_format: Target conversion format.
799
+ **options: Additional conversion options.
800
+
801
+ Returns:
802
+ Dict with jobId, status, and real-time streaming URLs.
803
+ """
804
+ # Get presigned URL
805
+ presign = self.get_presigned_upload_url(filename, content_type)
806
+
807
+ # Upload directly to R2
808
+ if hasattr(file_data, "read"):
809
+ data = file_data.read()
810
+ else:
811
+ data = file_data
812
+
813
+ resp = requests.put(
814
+ presign["uploadUrl"],
815
+ data=data,
816
+ headers={"Content-Type": content_type},
817
+ timeout=self.timeout,
818
+ )
819
+ resp.raise_for_status()
820
+
821
+ # Derive source format
822
+ ext = filename.rsplit(".", 1)[-1].lower() if "." in filename else ""
823
+
824
+ return self.convert_from_storage(
825
+ storage_key=presign["storageKey"],
826
+ source_format=ext,
827
+ target_format=target_format,
828
+ original_name=filename,
829
+ **options,
830
+ )
831
+
832
+ # =========================================================================
833
+ # Real-time Job Streaming (SSE)
834
+ # =========================================================================
835
+
836
+ def stream_job_updates(
837
+ self, job_id: str
838
+ ):
839
+ """Stream real-time job updates via Server-Sent Events (SSE).
840
+
841
+ Yields JobUpdate dicts as they arrive. Automatically closes on
842
+ terminal events (completed, failed).
843
+
844
+ Args:
845
+ job_id: The job ID to stream updates for.
846
+
847
+ Yields:
848
+ Dict with type, jobId, timestamp, progress, error, outputUrl, etc.
849
+
850
+ Example::
851
+
852
+ for update in client.stream_job_updates(job_id):
853
+ print(f"{update['type']}: {update.get('progress', 0)}%")
854
+ if update["type"] in ("completed", "failed"):
855
+ break
856
+ """
857
+ import json
858
+
859
+ base_origin = self.base_url.replace("/v1", "")
860
+ url = f"{base_origin}/sse/jobs/{job_id}"
861
+ headers = {
862
+ "Authorization": f"Bearer {self.api_key}",
863
+ "Accept": "text/event-stream",
864
+ "User-Agent": "convertintomp4-python/1.0.0",
865
+ }
866
+
867
+ with requests.get(url, headers=headers, stream=True, timeout=None) as resp:
868
+ resp.raise_for_status()
869
+
870
+ event_type = ""
871
+ event_data = ""
872
+
873
+ for line in resp.iter_lines(decode_unicode=True):
874
+ if line is None:
875
+ continue
876
+
877
+ if line.startswith("event: "):
878
+ event_type = line[7:].strip()
879
+ elif line.startswith("data: "):
880
+ event_data = line[6:].strip()
881
+ elif line == "" and event_data:
882
+ if event_type != "connected":
883
+ try:
884
+ update = json.loads(event_data)
885
+ yield update
886
+ if update.get("type") in ("completed", "failed"):
887
+ return
888
+ except json.JSONDecodeError:
889
+ pass
890
+ event_type = ""
891
+ event_data = ""