marqetive-lib 0.1.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.
Files changed (43) hide show
  1. marqetive/__init__.py +113 -0
  2. marqetive/core/__init__.py +5 -0
  3. marqetive/core/account_factory.py +212 -0
  4. marqetive/core/base_manager.py +303 -0
  5. marqetive/core/client.py +108 -0
  6. marqetive/core/progress.py +291 -0
  7. marqetive/core/registry.py +257 -0
  8. marqetive/platforms/__init__.py +55 -0
  9. marqetive/platforms/base.py +390 -0
  10. marqetive/platforms/exceptions.py +238 -0
  11. marqetive/platforms/instagram/__init__.py +7 -0
  12. marqetive/platforms/instagram/client.py +786 -0
  13. marqetive/platforms/instagram/exceptions.py +311 -0
  14. marqetive/platforms/instagram/factory.py +106 -0
  15. marqetive/platforms/instagram/manager.py +112 -0
  16. marqetive/platforms/instagram/media.py +669 -0
  17. marqetive/platforms/linkedin/__init__.py +7 -0
  18. marqetive/platforms/linkedin/client.py +733 -0
  19. marqetive/platforms/linkedin/exceptions.py +335 -0
  20. marqetive/platforms/linkedin/factory.py +130 -0
  21. marqetive/platforms/linkedin/manager.py +119 -0
  22. marqetive/platforms/linkedin/media.py +549 -0
  23. marqetive/platforms/models.py +345 -0
  24. marqetive/platforms/tiktok/__init__.py +0 -0
  25. marqetive/platforms/twitter/__init__.py +7 -0
  26. marqetive/platforms/twitter/client.py +647 -0
  27. marqetive/platforms/twitter/exceptions.py +311 -0
  28. marqetive/platforms/twitter/factory.py +151 -0
  29. marqetive/platforms/twitter/manager.py +121 -0
  30. marqetive/platforms/twitter/media.py +779 -0
  31. marqetive/platforms/twitter/threads.py +442 -0
  32. marqetive/py.typed +0 -0
  33. marqetive/registry_init.py +66 -0
  34. marqetive/utils/__init__.py +45 -0
  35. marqetive/utils/file_handlers.py +438 -0
  36. marqetive/utils/helpers.py +99 -0
  37. marqetive/utils/media.py +399 -0
  38. marqetive/utils/oauth.py +265 -0
  39. marqetive/utils/retry.py +239 -0
  40. marqetive/utils/token_validator.py +240 -0
  41. marqetive_lib-0.1.0.dist-info/METADATA +261 -0
  42. marqetive_lib-0.1.0.dist-info/RECORD +43 -0
  43. marqetive_lib-0.1.0.dist-info/WHEEL +4 -0
@@ -0,0 +1,779 @@
1
+ """Twitter media upload manager with chunked upload support.
2
+
3
+ This module provides comprehensive media upload functionality for Twitter API v2:
4
+ - Chunked upload for large files (videos, GIFs)
5
+ - Simple upload for images
6
+ - Progress tracking with callbacks
7
+ - Automatic retry with exponential backoff
8
+ - Async processing status monitoring
9
+ """
10
+
11
+ import asyncio
12
+ import logging
13
+ import os
14
+ from collections.abc import Callable
15
+ from dataclasses import dataclass
16
+ from enum import Enum
17
+ from typing import Any, Literal
18
+
19
+ import httpx
20
+
21
+ from marqetive.platforms.exceptions import (
22
+ InvalidFileTypeError,
23
+ MediaUploadError,
24
+ )
25
+ from marqetive.utils.file_handlers import download_file
26
+ from marqetive.utils.media import (
27
+ detect_mime_type,
28
+ format_file_size,
29
+ get_chunk_count,
30
+ )
31
+ from marqetive.utils.retry import STANDARD_BACKOFF, retry_async
32
+
33
+ logger = logging.getLogger(__name__)
34
+
35
+ # Constants
36
+ DEFAULT_CHUNK_SIZE = 4 * 1024 * 1024 # 4MB chunks
37
+ MAX_CHUNK_SIZE = 5 * 1024 * 1024 # 5MB max (Twitter limit)
38
+ MAX_IMAGE_SIZE = 5 * 1024 * 1024 # 5MB for images
39
+ MAX_GIF_SIZE = 15 * 1024 * 1024 # 15MB for GIFs
40
+ MAX_VIDEO_SIZE = 512 * 1024 * 1024 # 512MB for videos
41
+ DEFAULT_REQUEST_TIMEOUT = 120.0 # 2 minutes
42
+
43
+ # Twitter API v2 media upload endpoints
44
+ MEDIA_UPLOAD_BASE_URL = "https://upload.x.com/1.1/media"
45
+
46
+
47
+ class MediaCategory(str, Enum):
48
+ """Twitter media categories."""
49
+
50
+ TWEET_IMAGE = "tweet_image"
51
+ TWEET_VIDEO = "tweet_video"
52
+ TWEET_GIF = "tweet_gif"
53
+ AMPLIFY_VIDEO = "amplify_video"
54
+ DM_IMAGE = "dm_image"
55
+ DM_VIDEO = "dm_video"
56
+ DM_GIF = "dm_gif"
57
+ SUBTITLES = "subtitles"
58
+
59
+
60
+ class ProcessingState(str, Enum):
61
+ """States for async media processing."""
62
+
63
+ PENDING = "pending"
64
+ IN_PROGRESS = "in_progress"
65
+ SUCCEEDED = "succeeded"
66
+ FAILED = "failed"
67
+
68
+
69
+ # Supported MIME types for Twitter
70
+ SUPPORTED_IMAGE_TYPES = ["image/jpeg", "image/png", "image/webp"]
71
+ SUPPORTED_VIDEO_TYPES = ["video/mp4", "video/quicktime"]
72
+ SUPPORTED_GIF_TYPE = "image/gif"
73
+
74
+
75
+ @dataclass
76
+ class UploadProgress:
77
+ """Progress information for media upload.
78
+
79
+ Attributes:
80
+ media_id: Twitter media ID (if available).
81
+ file_path: Path to file being uploaded.
82
+ bytes_uploaded: Number of bytes uploaded so far.
83
+ total_bytes: Total file size in bytes.
84
+ percentage: Upload progress as percentage (0-100).
85
+ status: Current upload status.
86
+ """
87
+
88
+ media_id: str | None
89
+ file_path: str
90
+ bytes_uploaded: int
91
+ total_bytes: int
92
+ status: Literal["init", "uploading", "processing", "completed", "failed"]
93
+
94
+ @property
95
+ def percentage(self) -> float:
96
+ """Calculate upload percentage."""
97
+ if self.total_bytes == 0:
98
+ return 0.0
99
+ return (self.bytes_uploaded / self.total_bytes) * 100
100
+
101
+ def __str__(self) -> str:
102
+ """String representation of progress."""
103
+ return (
104
+ f"Upload Progress: {self.percentage:.1f}% "
105
+ f"({format_file_size(self.bytes_uploaded)} / "
106
+ f"{format_file_size(self.total_bytes)}) - {self.status}"
107
+ )
108
+
109
+
110
+ @dataclass
111
+ class MediaUploadResult:
112
+ """Result of a media upload operation.
113
+
114
+ Attributes:
115
+ media_id: Twitter media ID.
116
+ media_key: Twitter media key (if available).
117
+ size: File size in bytes.
118
+ expires_after_secs: Time until media expires.
119
+ processing_info: Processing status info (for videos).
120
+ """
121
+
122
+ media_id: str
123
+ media_key: str | None = None
124
+ size: int | None = None
125
+ expires_after_secs: int | None = None
126
+ processing_info: dict[str, Any] | None = None
127
+
128
+
129
+ class TwitterMediaManager:
130
+ """Manager for Twitter media uploads.
131
+
132
+ Handles both simple and chunked uploads with progress tracking.
133
+ Uses Twitter API v2 media upload endpoints.
134
+
135
+ Example:
136
+ >>> manager = TwitterMediaManager(bearer_token="your_token")
137
+ >>> result = await manager.upload_media("/path/to/image.jpg")
138
+ >>> print(f"Media ID: {result.media_id}")
139
+ """
140
+
141
+ def __init__(
142
+ self,
143
+ bearer_token: str,
144
+ *,
145
+ progress_callback: Callable[[UploadProgress], None] | None = None,
146
+ timeout: float = DEFAULT_REQUEST_TIMEOUT,
147
+ ) -> None:
148
+ """Initialize Twitter media manager.
149
+
150
+ Args:
151
+ bearer_token: Twitter OAuth 2.0 bearer token.
152
+ progress_callback: Optional callback for progress updates.
153
+ timeout: Request timeout in seconds.
154
+ """
155
+ self.bearer_token = bearer_token
156
+ self.progress_callback = progress_callback
157
+ self.timeout = timeout
158
+ self.base_url = MEDIA_UPLOAD_BASE_URL
159
+
160
+ # HTTP client
161
+ self.client = httpx.AsyncClient(
162
+ timeout=httpx.Timeout(timeout),
163
+ headers={"Authorization": f"Bearer {bearer_token}"},
164
+ )
165
+
166
+ async def __aenter__(self) -> "TwitterMediaManager":
167
+ """Enter async context."""
168
+ return self
169
+
170
+ async def __aexit__(self, *args: Any) -> None:
171
+ """Exit async context and cleanup."""
172
+ await self.client.aclose()
173
+
174
+ async def upload_media(
175
+ self,
176
+ file_path: str,
177
+ *,
178
+ media_category: MediaCategory | None = None,
179
+ alt_text: str | None = None,
180
+ additional_owners: list[str] | None = None,
181
+ ) -> MediaUploadResult:
182
+ """Upload media file to Twitter.
183
+
184
+ Automatically chooses between simple and chunked upload based on file type.
185
+
186
+ Args:
187
+ file_path: Path to media file or URL.
188
+ media_category: Twitter media category (auto-detected if None).
189
+ alt_text: Alternative text for accessibility.
190
+ additional_owners: Additional user IDs who can use this media.
191
+
192
+ Returns:
193
+ MediaUploadResult with media ID and metadata.
194
+
195
+ Raises:
196
+ InvalidFileTypeError: If file type is not supported.
197
+ MediaUploadError: If upload fails.
198
+ FileNotFoundError: If file doesn't exist.
199
+
200
+ Example:
201
+ >>> result = await manager.upload_media("photo.jpg")
202
+ >>> result = await manager.upload_media("video.mp4", alt_text="Demo video")
203
+ """
204
+ # Download file if it's a URL
205
+ if file_path.startswith(("http://", "https://")):
206
+ logger.info(f"Downloading media from URL: {file_path}")
207
+ file_path = await download_file(file_path)
208
+
209
+ # Validate file exists
210
+ if not os.path.exists(file_path):
211
+ raise FileNotFoundError(f"Media file not found: {file_path}")
212
+
213
+ # Detect MIME type
214
+ mime_type = detect_mime_type(file_path)
215
+ file_size = os.path.getsize(file_path)
216
+
217
+ # Auto-detect category if not provided
218
+ if media_category is None:
219
+ media_category = self._detect_media_category(mime_type)
220
+
221
+ # Validate file type
222
+ self._validate_media(mime_type, file_size)
223
+
224
+ # Choose upload method
225
+ if mime_type in SUPPORTED_VIDEO_TYPES or mime_type == SUPPORTED_GIF_TYPE:
226
+ result = await self.chunked_upload(
227
+ file_path,
228
+ media_category=media_category,
229
+ additional_owners=additional_owners,
230
+ )
231
+ else:
232
+ result = await self.simple_upload(
233
+ file_path,
234
+ media_category=media_category,
235
+ additional_owners=additional_owners,
236
+ )
237
+
238
+ # Set alt text if provided
239
+ if alt_text:
240
+ await self.add_alt_text(result.media_id, alt_text)
241
+
242
+ return result
243
+
244
+ async def simple_upload(
245
+ self,
246
+ file_path: str,
247
+ *,
248
+ media_category: MediaCategory | None = None,
249
+ additional_owners: list[str] | None = None,
250
+ ) -> MediaUploadResult:
251
+ """Upload media using simple upload (for images).
252
+
253
+ Args:
254
+ file_path: Path to media file.
255
+ media_category: Twitter media category.
256
+ additional_owners: Additional user IDs.
257
+
258
+ Returns:
259
+ MediaUploadResult with media ID.
260
+
261
+ Raises:
262
+ MediaUploadError: If upload fails.
263
+ """
264
+ file_size = os.path.getsize(file_path)
265
+
266
+ # Notify upload start
267
+ if self.progress_callback:
268
+ progress = UploadProgress(
269
+ media_id=None,
270
+ file_path=file_path,
271
+ bytes_uploaded=0,
272
+ total_bytes=file_size,
273
+ status="init",
274
+ )
275
+ self.progress_callback(progress)
276
+
277
+ @retry_async(config=STANDARD_BACKOFF)
278
+ async def _do_upload() -> MediaUploadResult:
279
+ # Read file
280
+ with open(file_path, "rb") as f:
281
+ file_data = f.read()
282
+
283
+ # Prepare form data
284
+ files = {"media": (os.path.basename(file_path), file_data)}
285
+ data = {}
286
+
287
+ if media_category:
288
+ data["media_category"] = media_category.value
289
+ if additional_owners:
290
+ data["additional_owners"] = ",".join(additional_owners)
291
+
292
+ # Notify upload in progress
293
+ if self.progress_callback:
294
+ progress = UploadProgress(
295
+ media_id=None,
296
+ file_path=file_path,
297
+ bytes_uploaded=0,
298
+ total_bytes=file_size,
299
+ status="uploading",
300
+ )
301
+ self.progress_callback(progress)
302
+
303
+ # Upload
304
+ response = await self.client.post(
305
+ f"{self.base_url}/upload.json",
306
+ files=files,
307
+ data=data,
308
+ )
309
+ response.raise_for_status()
310
+ result_data = response.json()
311
+
312
+ # Parse result
313
+ media_id = str(result_data["media_id"])
314
+ result = MediaUploadResult(
315
+ media_id=media_id,
316
+ media_key=result_data.get("media_key"),
317
+ size=result_data.get("size"),
318
+ expires_after_secs=result_data.get("expires_after_secs"),
319
+ )
320
+
321
+ # Notify completion
322
+ if self.progress_callback:
323
+ progress = UploadProgress(
324
+ media_id=media_id,
325
+ file_path=file_path,
326
+ bytes_uploaded=file_size,
327
+ total_bytes=file_size,
328
+ status="completed",
329
+ )
330
+ self.progress_callback(progress)
331
+
332
+ logger.info(f"Simple upload completed: {media_id}")
333
+ return result
334
+
335
+ try:
336
+ return await _do_upload()
337
+ except httpx.HTTPError as e:
338
+ raise MediaUploadError(
339
+ f"Simple upload failed: {e}",
340
+ platform="twitter",
341
+ media_type=detect_mime_type(file_path),
342
+ ) from e
343
+
344
+ async def chunked_upload(
345
+ self,
346
+ file_path: str,
347
+ *,
348
+ media_category: MediaCategory,
349
+ additional_owners: list[str] | None = None,
350
+ chunk_size: int = DEFAULT_CHUNK_SIZE,
351
+ wait_for_processing: bool = True,
352
+ ) -> MediaUploadResult:
353
+ """Upload media using chunked upload (INIT → APPEND → FINALIZE).
354
+
355
+ Used for large files like videos and GIFs.
356
+
357
+ Args:
358
+ file_path: Path to media file.
359
+ media_category: Twitter media category.
360
+ additional_owners: Additional user IDs.
361
+ chunk_size: Size of chunks (default: 4MB).
362
+ wait_for_processing: Wait for async processing to complete.
363
+
364
+ Returns:
365
+ MediaUploadResult with media ID.
366
+
367
+ Raises:
368
+ MediaUploadError: If upload or processing fails.
369
+ """
370
+ file_size = os.path.getsize(file_path)
371
+ mime_type = detect_mime_type(file_path)
372
+
373
+ # Calculate optimal chunk size
374
+ chunk_size = self._calculate_chunk_size(file_size, chunk_size)
375
+
376
+ # STEP 1: Initialize upload
377
+ media_id = await self._chunked_upload_init(
378
+ file_size,
379
+ mime_type,
380
+ media_category,
381
+ additional_owners,
382
+ )
383
+
384
+ logger.info(
385
+ f"Initialized chunked upload: media_id={media_id}, "
386
+ f"file_size={format_file_size(file_size)}, "
387
+ f"chunks={get_chunk_count(file_path, chunk_size)}"
388
+ )
389
+
390
+ # Notify upload start
391
+ if self.progress_callback:
392
+ progress = UploadProgress(
393
+ media_id=media_id,
394
+ file_path=file_path,
395
+ bytes_uploaded=0,
396
+ total_bytes=file_size,
397
+ status="uploading",
398
+ )
399
+ self.progress_callback(progress)
400
+
401
+ # STEP 2: Upload chunks
402
+ bytes_uploaded = 0
403
+ segment_index = 0
404
+
405
+ with open(file_path, "rb") as f:
406
+ while True:
407
+ chunk_data = f.read(chunk_size)
408
+ if not chunk_data:
409
+ break
410
+
411
+ await self._chunked_upload_append(
412
+ media_id,
413
+ chunk_data,
414
+ segment_index,
415
+ os.path.basename(file_path),
416
+ )
417
+
418
+ bytes_uploaded += len(chunk_data)
419
+ segment_index += 1
420
+
421
+ # Notify progress
422
+ if self.progress_callback:
423
+ progress = UploadProgress(
424
+ media_id=media_id,
425
+ file_path=file_path,
426
+ bytes_uploaded=bytes_uploaded,
427
+ total_bytes=file_size,
428
+ status="uploading",
429
+ )
430
+ self.progress_callback(progress)
431
+
432
+ logger.debug(
433
+ f"Uploaded chunk {segment_index}: "
434
+ f"{format_file_size(bytes_uploaded)} / "
435
+ f"{format_file_size(file_size)}"
436
+ )
437
+
438
+ # STEP 3: Finalize upload
439
+ result = await self._chunked_upload_finalize(media_id)
440
+
441
+ # STEP 4: Wait for async processing if needed
442
+ if wait_for_processing and result.processing_info:
443
+ await self._wait_for_processing(result, file_path)
444
+
445
+ # Notify completion
446
+ if self.progress_callback:
447
+ progress = UploadProgress(
448
+ media_id=media_id,
449
+ file_path=file_path,
450
+ bytes_uploaded=file_size,
451
+ total_bytes=file_size,
452
+ status="completed",
453
+ )
454
+ self.progress_callback(progress)
455
+
456
+ logger.info(f"Chunked upload completed: {media_id}")
457
+ return result
458
+
459
+ async def _chunked_upload_init(
460
+ self,
461
+ total_bytes: int,
462
+ media_type: str,
463
+ media_category: MediaCategory,
464
+ additional_owners: list[str] | None,
465
+ ) -> str:
466
+ """Initialize chunked upload (INIT command).
467
+
468
+ Args:
469
+ total_bytes: Total file size.
470
+ media_type: MIME type.
471
+ media_category: Twitter media category.
472
+ additional_owners: Additional user IDs.
473
+
474
+ Returns:
475
+ Media ID for subsequent operations.
476
+ """
477
+
478
+ @retry_async(config=STANDARD_BACKOFF)
479
+ async def _do_init() -> str:
480
+ params = {
481
+ "command": "INIT",
482
+ "total_bytes": total_bytes,
483
+ "media_type": media_type,
484
+ "media_category": media_category.value,
485
+ }
486
+
487
+ if additional_owners:
488
+ params["additional_owners"] = ",".join(additional_owners)
489
+
490
+ response = await self.client.post(
491
+ f"{self.base_url}/upload.json",
492
+ params=params,
493
+ )
494
+ response.raise_for_status()
495
+ result = response.json()
496
+ return str(result["media_id"])
497
+
498
+ return await _do_init()
499
+
500
+ async def _chunked_upload_append(
501
+ self,
502
+ media_id: str,
503
+ chunk_data: bytes,
504
+ segment_index: int,
505
+ filename: str,
506
+ ) -> None:
507
+ """Append chunk to upload (APPEND command).
508
+
509
+ Args:
510
+ media_id: Media ID from INIT.
511
+ chunk_data: Chunk bytes.
512
+ segment_index: Sequential chunk index.
513
+ filename: Original filename.
514
+ """
515
+
516
+ @retry_async(config=STANDARD_BACKOFF)
517
+ async def _do_append() -> None:
518
+ files = {"media": (filename, chunk_data)}
519
+ params = {
520
+ "command": "APPEND",
521
+ "media_id": media_id,
522
+ "segment_index": segment_index,
523
+ }
524
+
525
+ response = await self.client.post(
526
+ f"{self.base_url}/upload.json",
527
+ params=params,
528
+ files=files,
529
+ )
530
+ response.raise_for_status()
531
+
532
+ await _do_append()
533
+
534
+ async def _chunked_upload_finalize(self, media_id: str) -> MediaUploadResult:
535
+ """Finalize chunked upload (FINALIZE command).
536
+
537
+ Args:
538
+ media_id: Media ID from INIT.
539
+
540
+ Returns:
541
+ MediaUploadResult with processing info.
542
+ """
543
+
544
+ @retry_async(config=STANDARD_BACKOFF)
545
+ async def _do_finalize() -> MediaUploadResult:
546
+ params = {
547
+ "command": "FINALIZE",
548
+ "media_id": media_id,
549
+ }
550
+
551
+ response = await self.client.post(
552
+ f"{self.base_url}/upload.json",
553
+ params=params,
554
+ )
555
+ response.raise_for_status()
556
+ result = response.json()
557
+
558
+ return MediaUploadResult(
559
+ media_id=str(result["media_id"]),
560
+ media_key=result.get("media_key"),
561
+ size=result.get("size"),
562
+ expires_after_secs=result.get("expires_after_secs"),
563
+ processing_info=result.get("processing_info"),
564
+ )
565
+
566
+ return await _do_finalize()
567
+
568
+ async def get_upload_status(self, media_id: str) -> MediaUploadResult:
569
+ """Check status of async media processing (STATUS command).
570
+
571
+ Args:
572
+ media_id: Media ID to check.
573
+
574
+ Returns:
575
+ MediaUploadResult with current processing status.
576
+ """
577
+
578
+ @retry_async(config=STANDARD_BACKOFF)
579
+ async def _do_status() -> MediaUploadResult:
580
+ params = {
581
+ "command": "STATUS",
582
+ "media_id": media_id,
583
+ }
584
+
585
+ response = await self.client.get(
586
+ f"{self.base_url}/upload.json",
587
+ params=params,
588
+ )
589
+ response.raise_for_status()
590
+ result = response.json()
591
+
592
+ return MediaUploadResult(
593
+ media_id=str(result["media_id"]),
594
+ media_key=result.get("media_key"),
595
+ size=result.get("size"),
596
+ expires_after_secs=result.get("expires_after_secs"),
597
+ processing_info=result.get("processing_info"),
598
+ )
599
+
600
+ return await _do_status()
601
+
602
+ async def add_alt_text(self, media_id: str, alt_text: str) -> None:
603
+ """Add alternative text to media for accessibility.
604
+
605
+ Args:
606
+ media_id: Twitter media ID.
607
+ alt_text: Alternative text description (max 1000 chars).
608
+
609
+ Raises:
610
+ MediaUploadError: If alt text addition fails.
611
+ """
612
+ if len(alt_text) > 1000:
613
+ raise MediaUploadError(
614
+ "Alt text must be 1000 characters or less",
615
+ platform="twitter",
616
+ )
617
+
618
+ try:
619
+ response = await self.client.post(
620
+ f"{self.base_url}/metadata/create.json",
621
+ json={
622
+ "media_id": media_id,
623
+ "alt_text": {"text": alt_text},
624
+ },
625
+ )
626
+ response.raise_for_status()
627
+ logger.info(f"Added alt text to media: {media_id}")
628
+
629
+ except httpx.HTTPError as e:
630
+ raise MediaUploadError(
631
+ f"Failed to add alt text: {e}",
632
+ platform="twitter",
633
+ ) from e
634
+
635
+ async def _wait_for_processing(
636
+ self,
637
+ result: MediaUploadResult,
638
+ file_path: str,
639
+ ) -> None:
640
+ """Wait for async processing to complete.
641
+
642
+ Args:
643
+ result: Upload result with processing info.
644
+ file_path: File path for progress tracking.
645
+
646
+ Raises:
647
+ MediaUploadError: If processing fails.
648
+ """
649
+ if not result.processing_info:
650
+ return
651
+
652
+ processing_info = result.processing_info
653
+ state = processing_info.get("state")
654
+
655
+ while state in (
656
+ ProcessingState.PENDING.value,
657
+ ProcessingState.IN_PROGRESS.value,
658
+ ):
659
+ check_after = processing_info.get("check_after_secs", 5)
660
+ logger.info(
661
+ f"Media processing {state}, checking again in {check_after}s..."
662
+ )
663
+
664
+ # Notify processing status
665
+ if self.progress_callback:
666
+ progress = UploadProgress(
667
+ media_id=result.media_id,
668
+ file_path=file_path,
669
+ bytes_uploaded=os.path.getsize(file_path),
670
+ total_bytes=os.path.getsize(file_path),
671
+ status="processing",
672
+ )
673
+ self.progress_callback(progress)
674
+
675
+ await asyncio.sleep(check_after)
676
+
677
+ # Check status
678
+ result = await self.get_upload_status(result.media_id)
679
+ processing_info = result.processing_info or {}
680
+ state = processing_info.get("state")
681
+
682
+ # Check final state
683
+ if state == ProcessingState.FAILED.value:
684
+ error_msg = processing_info.get("error", {}).get("message", "Unknown error")
685
+ raise MediaUploadError(
686
+ f"Media processing failed: {error_msg}",
687
+ platform="twitter",
688
+ )
689
+
690
+ if state != ProcessingState.SUCCEEDED.value:
691
+ raise MediaUploadError(
692
+ f"Media processing ended in unexpected state: {state}",
693
+ platform="twitter",
694
+ )
695
+
696
+ logger.info(f"Media processing succeeded: {result.media_id}")
697
+
698
+ def _detect_media_category(self, mime_type: str) -> MediaCategory:
699
+ """Auto-detect Twitter media category from MIME type.
700
+
701
+ Args:
702
+ mime_type: MIME type string.
703
+
704
+ Returns:
705
+ Appropriate MediaCategory.
706
+ """
707
+ if mime_type in SUPPORTED_IMAGE_TYPES:
708
+ return MediaCategory.TWEET_IMAGE
709
+ elif mime_type == SUPPORTED_GIF_TYPE:
710
+ return MediaCategory.TWEET_GIF
711
+ elif mime_type in SUPPORTED_VIDEO_TYPES:
712
+ return MediaCategory.TWEET_VIDEO
713
+ else:
714
+ return MediaCategory.TWEET_IMAGE # Default
715
+
716
+ def _validate_media(self, mime_type: str, file_size: int) -> None:
717
+ """Validate media type and size.
718
+
719
+ Args:
720
+ mime_type: MIME type of file.
721
+ file_size: Size in bytes.
722
+
723
+ Raises:
724
+ InvalidFileTypeError: If type not supported.
725
+ MediaUploadError: If file exceeds size limit.
726
+ """
727
+ all_supported = (
728
+ SUPPORTED_IMAGE_TYPES + SUPPORTED_VIDEO_TYPES + [SUPPORTED_GIF_TYPE]
729
+ )
730
+
731
+ if mime_type not in all_supported:
732
+ raise InvalidFileTypeError(
733
+ f"Unsupported media type: {mime_type}. "
734
+ f"Supported: {', '.join(all_supported)}",
735
+ platform="twitter",
736
+ )
737
+
738
+ # Check size limits
739
+ if mime_type in SUPPORTED_IMAGE_TYPES and file_size > MAX_IMAGE_SIZE:
740
+ raise MediaUploadError(
741
+ f"Image exceeds {format_file_size(MAX_IMAGE_SIZE)} limit",
742
+ platform="twitter",
743
+ media_type=mime_type,
744
+ )
745
+ elif mime_type == SUPPORTED_GIF_TYPE and file_size > MAX_GIF_SIZE:
746
+ raise MediaUploadError(
747
+ f"GIF exceeds {format_file_size(MAX_GIF_SIZE)} limit",
748
+ platform="twitter",
749
+ media_type=mime_type,
750
+ )
751
+ elif mime_type in SUPPORTED_VIDEO_TYPES and file_size > MAX_VIDEO_SIZE:
752
+ raise MediaUploadError(
753
+ f"Video exceeds {format_file_size(MAX_VIDEO_SIZE)} limit",
754
+ platform="twitter",
755
+ media_type=mime_type,
756
+ )
757
+
758
+ def _calculate_chunk_size(self, file_size: int, requested_chunk_size: int) -> int:
759
+ """Calculate optimal chunk size for upload.
760
+
761
+ Twitter requires minimum 1000 chunks, maximum 999 chunks.
762
+
763
+ Args:
764
+ file_size: Total file size.
765
+ requested_chunk_size: Requested chunk size.
766
+
767
+ Returns:
768
+ Optimal chunk size in bytes.
769
+ """
770
+ # Minimum chunk size (file_size / 999)
771
+ min_chunk_size = (file_size + 998) // 999
772
+
773
+ # Maximum chunk size (Twitter API limit)
774
+ max_chunk_size = MAX_CHUNK_SIZE
775
+
776
+ # Ensure requested size is within bounds
777
+ chunk_size = max(min(requested_chunk_size, max_chunk_size), min_chunk_size)
778
+
779
+ return chunk_size