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.
- marqetive/__init__.py +113 -0
- marqetive/core/__init__.py +5 -0
- marqetive/core/account_factory.py +212 -0
- marqetive/core/base_manager.py +303 -0
- marqetive/core/client.py +108 -0
- marqetive/core/progress.py +291 -0
- marqetive/core/registry.py +257 -0
- marqetive/platforms/__init__.py +55 -0
- marqetive/platforms/base.py +390 -0
- marqetive/platforms/exceptions.py +238 -0
- marqetive/platforms/instagram/__init__.py +7 -0
- marqetive/platforms/instagram/client.py +786 -0
- marqetive/platforms/instagram/exceptions.py +311 -0
- marqetive/platforms/instagram/factory.py +106 -0
- marqetive/platforms/instagram/manager.py +112 -0
- marqetive/platforms/instagram/media.py +669 -0
- marqetive/platforms/linkedin/__init__.py +7 -0
- marqetive/platforms/linkedin/client.py +733 -0
- marqetive/platforms/linkedin/exceptions.py +335 -0
- marqetive/platforms/linkedin/factory.py +130 -0
- marqetive/platforms/linkedin/manager.py +119 -0
- marqetive/platforms/linkedin/media.py +549 -0
- marqetive/platforms/models.py +345 -0
- marqetive/platforms/tiktok/__init__.py +0 -0
- marqetive/platforms/twitter/__init__.py +7 -0
- marqetive/platforms/twitter/client.py +647 -0
- marqetive/platforms/twitter/exceptions.py +311 -0
- marqetive/platforms/twitter/factory.py +151 -0
- marqetive/platforms/twitter/manager.py +121 -0
- marqetive/platforms/twitter/media.py +779 -0
- marqetive/platforms/twitter/threads.py +442 -0
- marqetive/py.typed +0 -0
- marqetive/registry_init.py +66 -0
- marqetive/utils/__init__.py +45 -0
- marqetive/utils/file_handlers.py +438 -0
- marqetive/utils/helpers.py +99 -0
- marqetive/utils/media.py +399 -0
- marqetive/utils/oauth.py +265 -0
- marqetive/utils/retry.py +239 -0
- marqetive/utils/token_validator.py +240 -0
- marqetive_lib-0.1.0.dist-info/METADATA +261 -0
- marqetive_lib-0.1.0.dist-info/RECORD +43 -0
- 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
|