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,549 @@
|
|
|
1
|
+
"""LinkedIn media upload manager with support for images, videos, and documents.
|
|
2
|
+
|
|
3
|
+
LinkedIn uses a multi-step upload process:
|
|
4
|
+
1. Register upload (get upload URL)
|
|
5
|
+
2. Upload file to the URL
|
|
6
|
+
3. Complete upload (finalize)
|
|
7
|
+
|
|
8
|
+
This module supports:
|
|
9
|
+
- Image uploads (single and multiple)
|
|
10
|
+
- Video uploads with processing monitoring
|
|
11
|
+
- Document/PDF uploads
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import asyncio
|
|
15
|
+
import logging
|
|
16
|
+
from collections.abc import Callable
|
|
17
|
+
from dataclasses import dataclass
|
|
18
|
+
from enum import Enum
|
|
19
|
+
from typing import Any, Literal
|
|
20
|
+
|
|
21
|
+
import httpx
|
|
22
|
+
|
|
23
|
+
from marqetive.platforms.exceptions import (
|
|
24
|
+
MediaUploadError,
|
|
25
|
+
ValidationError,
|
|
26
|
+
)
|
|
27
|
+
from marqetive.utils.file_handlers import download_file, read_file_bytes
|
|
28
|
+
from marqetive.utils.media import detect_mime_type, format_file_size
|
|
29
|
+
from marqetive.utils.retry import STANDARD_BACKOFF, retry_async
|
|
30
|
+
|
|
31
|
+
logger = logging.getLogger(__name__)
|
|
32
|
+
|
|
33
|
+
# LinkedIn limits
|
|
34
|
+
MAX_IMAGE_SIZE = 10 * 1024 * 1024 # 10MB
|
|
35
|
+
MAX_VIDEO_SIZE = 200 * 1024 * 1024 # 200MB
|
|
36
|
+
MAX_DOCUMENT_SIZE = 10 * 1024 * 1024 # 10MB
|
|
37
|
+
MAX_VIDEO_DURATION = 600 # 10 minutes
|
|
38
|
+
|
|
39
|
+
# Processing timeouts
|
|
40
|
+
VIDEO_PROCESSING_TIMEOUT = 600 # 10 minutes
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class VideoProcessingState(str, Enum):
|
|
44
|
+
"""LinkedIn video processing states."""
|
|
45
|
+
|
|
46
|
+
PROCESSING = "PROCESSING"
|
|
47
|
+
READY = "READY"
|
|
48
|
+
FAILED = "FAILED"
|
|
49
|
+
AVAILABLE = "AVAILABLE"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass
|
|
53
|
+
class UploadProgress:
|
|
54
|
+
"""Progress information for media upload."""
|
|
55
|
+
|
|
56
|
+
asset_id: str
|
|
57
|
+
bytes_uploaded: int
|
|
58
|
+
total_bytes: int
|
|
59
|
+
status: Literal["registering", "uploading", "processing", "completed", "failed"]
|
|
60
|
+
|
|
61
|
+
@property
|
|
62
|
+
def percentage(self) -> float:
|
|
63
|
+
"""Calculate upload percentage."""
|
|
64
|
+
if self.total_bytes == 0:
|
|
65
|
+
return 0.0
|
|
66
|
+
return (self.bytes_uploaded / self.total_bytes) * 100
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@dataclass
|
|
70
|
+
class MediaAsset:
|
|
71
|
+
"""LinkedIn media asset result.
|
|
72
|
+
|
|
73
|
+
Attributes:
|
|
74
|
+
asset_id: LinkedIn asset URN.
|
|
75
|
+
download_url: URL to download the media (if available).
|
|
76
|
+
status: Current status of the asset.
|
|
77
|
+
"""
|
|
78
|
+
|
|
79
|
+
asset_id: str
|
|
80
|
+
download_url: str | None = None
|
|
81
|
+
status: str | None = None
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class LinkedInMediaManager:
|
|
85
|
+
"""Manager for LinkedIn media uploads.
|
|
86
|
+
|
|
87
|
+
Supports images, videos, and documents with progress tracking.
|
|
88
|
+
|
|
89
|
+
Example:
|
|
90
|
+
>>> manager = LinkedInMediaManager(person_urn, access_token)
|
|
91
|
+
>>> asset = await manager.upload_image("/path/to/image.jpg")
|
|
92
|
+
>>> print(f"Uploaded: {asset.asset_id}")
|
|
93
|
+
"""
|
|
94
|
+
|
|
95
|
+
def __init__(
|
|
96
|
+
self,
|
|
97
|
+
person_urn: str,
|
|
98
|
+
access_token: str,
|
|
99
|
+
*,
|
|
100
|
+
api_version: str = "v2",
|
|
101
|
+
timeout: float = 60.0,
|
|
102
|
+
progress_callback: Callable[[UploadProgress], None] | None = None,
|
|
103
|
+
) -> None:
|
|
104
|
+
"""Initialize LinkedIn media manager.
|
|
105
|
+
|
|
106
|
+
Args:
|
|
107
|
+
person_urn: LinkedIn person URN (e.g., "urn:li:person:ABC123").
|
|
108
|
+
access_token: LinkedIn OAuth access token.
|
|
109
|
+
api_version: LinkedIn API version.
|
|
110
|
+
timeout: Request timeout in seconds.
|
|
111
|
+
progress_callback: Optional callback for progress updates.
|
|
112
|
+
"""
|
|
113
|
+
self.person_urn = person_urn
|
|
114
|
+
self.access_token = access_token
|
|
115
|
+
self.api_version = api_version
|
|
116
|
+
self.timeout = timeout
|
|
117
|
+
self.progress_callback = progress_callback
|
|
118
|
+
|
|
119
|
+
self.base_url = f"https://api.linkedin.com/{api_version}"
|
|
120
|
+
self.client = httpx.AsyncClient(
|
|
121
|
+
timeout=httpx.Timeout(timeout),
|
|
122
|
+
headers={
|
|
123
|
+
"Authorization": f"Bearer {access_token}",
|
|
124
|
+
"X-Restli-Protocol-Version": "2.0.0",
|
|
125
|
+
},
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
async def __aenter__(self) -> "LinkedInMediaManager":
|
|
129
|
+
"""Enter async context."""
|
|
130
|
+
return self
|
|
131
|
+
|
|
132
|
+
async def __aexit__(self, *args: Any) -> None:
|
|
133
|
+
"""Exit async context and cleanup."""
|
|
134
|
+
await self.client.aclose()
|
|
135
|
+
|
|
136
|
+
async def upload_image(
|
|
137
|
+
self,
|
|
138
|
+
file_path: str,
|
|
139
|
+
*,
|
|
140
|
+
alt_text: str | None = None, # noqa: ARG002
|
|
141
|
+
) -> MediaAsset:
|
|
142
|
+
"""Upload an image to LinkedIn.
|
|
143
|
+
|
|
144
|
+
Args:
|
|
145
|
+
file_path: Path to image file or URL.
|
|
146
|
+
alt_text: Alternative text for accessibility.
|
|
147
|
+
|
|
148
|
+
Returns:
|
|
149
|
+
MediaAsset with asset ID.
|
|
150
|
+
|
|
151
|
+
Raises:
|
|
152
|
+
MediaUploadError: If upload fails.
|
|
153
|
+
ValidationError: If file is invalid.
|
|
154
|
+
|
|
155
|
+
Example:
|
|
156
|
+
>>> asset = await manager.upload_image("photo.jpg")
|
|
157
|
+
>>> print(f"Asset ID: {asset.asset_id}")
|
|
158
|
+
"""
|
|
159
|
+
# Download if URL
|
|
160
|
+
if file_path.startswith(("http://", "https://")):
|
|
161
|
+
file_path = await download_file(file_path)
|
|
162
|
+
|
|
163
|
+
# Validate
|
|
164
|
+
mime_type = detect_mime_type(file_path)
|
|
165
|
+
if not mime_type.startswith("image/"):
|
|
166
|
+
raise ValidationError(
|
|
167
|
+
f"File is not an image: {mime_type}",
|
|
168
|
+
platform="linkedin",
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
# Read file
|
|
172
|
+
file_bytes = await read_file_bytes(file_path)
|
|
173
|
+
file_size = len(file_bytes)
|
|
174
|
+
|
|
175
|
+
if file_size > MAX_IMAGE_SIZE:
|
|
176
|
+
raise ValidationError(
|
|
177
|
+
f"Image exceeds {format_file_size(MAX_IMAGE_SIZE)} limit",
|
|
178
|
+
platform="linkedin",
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
# Register upload
|
|
182
|
+
register_data = {
|
|
183
|
+
"registerUploadRequest": {
|
|
184
|
+
"recipes": ["urn:li:digitalmediaRecipe:feedshare-image"],
|
|
185
|
+
"owner": self.person_urn,
|
|
186
|
+
"serviceRelationships": [
|
|
187
|
+
{
|
|
188
|
+
"relationshipType": "OWNER",
|
|
189
|
+
"identifier": "urn:li:userGeneratedContent",
|
|
190
|
+
}
|
|
191
|
+
],
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
asset_id = await self._register_upload(register_data)
|
|
196
|
+
|
|
197
|
+
# Notify start
|
|
198
|
+
if self.progress_callback:
|
|
199
|
+
self.progress_callback(
|
|
200
|
+
UploadProgress(asset_id, 0, file_size, "registering")
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
# Get upload URL
|
|
204
|
+
upload_url = await self._get_upload_url(asset_id)
|
|
205
|
+
|
|
206
|
+
# Upload file
|
|
207
|
+
await self._upload_to_url(upload_url, file_bytes, file_size, asset_id)
|
|
208
|
+
|
|
209
|
+
# Notify completion
|
|
210
|
+
if self.progress_callback:
|
|
211
|
+
self.progress_callback(
|
|
212
|
+
UploadProgress(asset_id, file_size, file_size, "completed")
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
logger.info(f"Image uploaded successfully: {asset_id}")
|
|
216
|
+
return MediaAsset(asset_id=asset_id, status="READY")
|
|
217
|
+
|
|
218
|
+
async def upload_video(
|
|
219
|
+
self,
|
|
220
|
+
file_path: str,
|
|
221
|
+
*,
|
|
222
|
+
title: str | None = None, # noqa: ARG002
|
|
223
|
+
wait_for_processing: bool = True,
|
|
224
|
+
) -> MediaAsset:
|
|
225
|
+
"""Upload a video to LinkedIn.
|
|
226
|
+
|
|
227
|
+
Args:
|
|
228
|
+
file_path: Path to video file or URL.
|
|
229
|
+
title: Video title.
|
|
230
|
+
wait_for_processing: Wait for video processing to complete.
|
|
231
|
+
|
|
232
|
+
Returns:
|
|
233
|
+
MediaAsset with asset ID.
|
|
234
|
+
|
|
235
|
+
Raises:
|
|
236
|
+
MediaUploadError: If upload or processing fails.
|
|
237
|
+
ValidationError: If file is invalid.
|
|
238
|
+
|
|
239
|
+
Example:
|
|
240
|
+
>>> asset = await manager.upload_video(
|
|
241
|
+
... "video.mp4",
|
|
242
|
+
... title="My Video",
|
|
243
|
+
... wait_for_processing=True
|
|
244
|
+
... )
|
|
245
|
+
"""
|
|
246
|
+
# Download if URL
|
|
247
|
+
if file_path.startswith(("http://", "https://")):
|
|
248
|
+
file_path = await download_file(file_path)
|
|
249
|
+
|
|
250
|
+
# Validate
|
|
251
|
+
mime_type = detect_mime_type(file_path)
|
|
252
|
+
if not mime_type.startswith("video/"):
|
|
253
|
+
raise ValidationError(
|
|
254
|
+
f"File is not a video: {mime_type}",
|
|
255
|
+
platform="linkedin",
|
|
256
|
+
)
|
|
257
|
+
|
|
258
|
+
# Read file
|
|
259
|
+
file_bytes = await read_file_bytes(file_path)
|
|
260
|
+
file_size = len(file_bytes)
|
|
261
|
+
|
|
262
|
+
if file_size > MAX_VIDEO_SIZE:
|
|
263
|
+
raise ValidationError(
|
|
264
|
+
f"Video exceeds {format_file_size(MAX_VIDEO_SIZE)} limit",
|
|
265
|
+
platform="linkedin",
|
|
266
|
+
)
|
|
267
|
+
|
|
268
|
+
# Register upload
|
|
269
|
+
register_data = {
|
|
270
|
+
"registerUploadRequest": {
|
|
271
|
+
"recipes": ["urn:li:digitalmediaRecipe:feedshare-video"],
|
|
272
|
+
"owner": self.person_urn,
|
|
273
|
+
"serviceRelationships": [
|
|
274
|
+
{
|
|
275
|
+
"relationshipType": "OWNER",
|
|
276
|
+
"identifier": "urn:li:userGeneratedContent",
|
|
277
|
+
}
|
|
278
|
+
],
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
asset_id = await self._register_upload(register_data)
|
|
283
|
+
|
|
284
|
+
# Notify start
|
|
285
|
+
if self.progress_callback:
|
|
286
|
+
self.progress_callback(
|
|
287
|
+
UploadProgress(asset_id, 0, file_size, "registering")
|
|
288
|
+
)
|
|
289
|
+
|
|
290
|
+
# Get upload URL
|
|
291
|
+
upload_url = await self._get_upload_url(asset_id)
|
|
292
|
+
|
|
293
|
+
# Upload file
|
|
294
|
+
await self._upload_to_url(upload_url, file_bytes, file_size, asset_id)
|
|
295
|
+
|
|
296
|
+
# Wait for processing if requested
|
|
297
|
+
if wait_for_processing:
|
|
298
|
+
await self._wait_for_video_processing(asset_id)
|
|
299
|
+
|
|
300
|
+
# Notify completion
|
|
301
|
+
if self.progress_callback:
|
|
302
|
+
status = "completed" if wait_for_processing else "processing"
|
|
303
|
+
self.progress_callback(
|
|
304
|
+
UploadProgress(asset_id, file_size, file_size, status)
|
|
305
|
+
)
|
|
306
|
+
|
|
307
|
+
logger.info(f"Video uploaded successfully: {asset_id}")
|
|
308
|
+
return MediaAsset(
|
|
309
|
+
asset_id=asset_id, status="READY" if wait_for_processing else "PROCESSING"
|
|
310
|
+
)
|
|
311
|
+
|
|
312
|
+
async def upload_document(
|
|
313
|
+
self,
|
|
314
|
+
file_path: str,
|
|
315
|
+
*,
|
|
316
|
+
title: str | None = None, # noqa: ARG002
|
|
317
|
+
) -> MediaAsset:
|
|
318
|
+
"""Upload a document/PDF to LinkedIn.
|
|
319
|
+
|
|
320
|
+
Args:
|
|
321
|
+
file_path: Path to document file or URL.
|
|
322
|
+
title: Document title.
|
|
323
|
+
|
|
324
|
+
Returns:
|
|
325
|
+
MediaAsset with asset ID.
|
|
326
|
+
|
|
327
|
+
Raises:
|
|
328
|
+
MediaUploadError: If upload fails.
|
|
329
|
+
ValidationError: If file is invalid.
|
|
330
|
+
|
|
331
|
+
Example:
|
|
332
|
+
>>> asset = await manager.upload_document(
|
|
333
|
+
... "presentation.pdf",
|
|
334
|
+
... title="Q4 Report"
|
|
335
|
+
... )
|
|
336
|
+
"""
|
|
337
|
+
# Download if URL
|
|
338
|
+
if file_path.startswith(("http://", "https://")):
|
|
339
|
+
file_path = await download_file(file_path)
|
|
340
|
+
|
|
341
|
+
# Validate
|
|
342
|
+
mime_type = detect_mime_type(file_path)
|
|
343
|
+
if mime_type != "application/pdf":
|
|
344
|
+
raise ValidationError(
|
|
345
|
+
f"Only PDF documents are supported. Got: {mime_type}",
|
|
346
|
+
platform="linkedin",
|
|
347
|
+
)
|
|
348
|
+
|
|
349
|
+
# Read file
|
|
350
|
+
file_bytes = await read_file_bytes(file_path)
|
|
351
|
+
file_size = len(file_bytes)
|
|
352
|
+
|
|
353
|
+
if file_size > MAX_DOCUMENT_SIZE:
|
|
354
|
+
raise ValidationError(
|
|
355
|
+
f"Document exceeds {format_file_size(MAX_DOCUMENT_SIZE)} limit",
|
|
356
|
+
platform="linkedin",
|
|
357
|
+
)
|
|
358
|
+
|
|
359
|
+
# Register upload
|
|
360
|
+
register_data = {
|
|
361
|
+
"registerUploadRequest": {
|
|
362
|
+
"recipes": ["urn:li:digitalmediaRecipe:feedshare-document"],
|
|
363
|
+
"owner": self.person_urn,
|
|
364
|
+
"serviceRelationships": [
|
|
365
|
+
{
|
|
366
|
+
"relationshipType": "OWNER",
|
|
367
|
+
"identifier": "urn:li:userGeneratedContent",
|
|
368
|
+
}
|
|
369
|
+
],
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
asset_id = await self._register_upload(register_data)
|
|
374
|
+
|
|
375
|
+
# Notify start
|
|
376
|
+
if self.progress_callback:
|
|
377
|
+
self.progress_callback(
|
|
378
|
+
UploadProgress(asset_id, 0, file_size, "registering")
|
|
379
|
+
)
|
|
380
|
+
|
|
381
|
+
# Get upload URL
|
|
382
|
+
upload_url = await self._get_upload_url(asset_id)
|
|
383
|
+
|
|
384
|
+
# Upload file
|
|
385
|
+
await self._upload_to_url(upload_url, file_bytes, file_size, asset_id)
|
|
386
|
+
|
|
387
|
+
# Notify completion
|
|
388
|
+
if self.progress_callback:
|
|
389
|
+
self.progress_callback(
|
|
390
|
+
UploadProgress(asset_id, file_size, file_size, "completed")
|
|
391
|
+
)
|
|
392
|
+
|
|
393
|
+
logger.info(f"Document uploaded successfully: {asset_id}")
|
|
394
|
+
return MediaAsset(asset_id=asset_id, status="READY")
|
|
395
|
+
|
|
396
|
+
async def get_video_status(self, asset_id: str) -> dict[str, Any]:
|
|
397
|
+
"""Get processing status of a video asset.
|
|
398
|
+
|
|
399
|
+
Args:
|
|
400
|
+
asset_id: LinkedIn asset URN.
|
|
401
|
+
|
|
402
|
+
Returns:
|
|
403
|
+
Dictionary with video status information.
|
|
404
|
+
|
|
405
|
+
Example:
|
|
406
|
+
>>> status = await manager.get_video_status(asset_id)
|
|
407
|
+
>>> print(f"Status: {status['status']}")
|
|
408
|
+
"""
|
|
409
|
+
|
|
410
|
+
@retry_async(config=STANDARD_BACKOFF)
|
|
411
|
+
async def _get_status() -> dict[str, Any]:
|
|
412
|
+
response = await self.client.get(
|
|
413
|
+
f"{self.base_url}/assets/{asset_id}",
|
|
414
|
+
)
|
|
415
|
+
response.raise_for_status()
|
|
416
|
+
return response.json()
|
|
417
|
+
|
|
418
|
+
return await _get_status()
|
|
419
|
+
|
|
420
|
+
async def _register_upload(self, register_data: dict[str, Any]) -> str:
|
|
421
|
+
"""Register an upload and get asset ID."""
|
|
422
|
+
|
|
423
|
+
@retry_async(config=STANDARD_BACKOFF)
|
|
424
|
+
async def _register() -> str:
|
|
425
|
+
response = await self.client.post(
|
|
426
|
+
f"{self.base_url}/assets?action=registerUpload",
|
|
427
|
+
json=register_data,
|
|
428
|
+
)
|
|
429
|
+
response.raise_for_status()
|
|
430
|
+
result = response.json()
|
|
431
|
+
return result["value"]["asset"]
|
|
432
|
+
|
|
433
|
+
try:
|
|
434
|
+
return await _register()
|
|
435
|
+
except httpx.HTTPError as e:
|
|
436
|
+
raise MediaUploadError(
|
|
437
|
+
f"Failed to register upload: {e}",
|
|
438
|
+
platform="linkedin",
|
|
439
|
+
) from e
|
|
440
|
+
|
|
441
|
+
async def _get_upload_url(self, asset_id: str) -> str:
|
|
442
|
+
"""Get upload URL for an asset."""
|
|
443
|
+
|
|
444
|
+
@retry_async(config=STANDARD_BACKOFF)
|
|
445
|
+
async def _get_url() -> str:
|
|
446
|
+
response = await self.client.get(
|
|
447
|
+
f"{self.base_url}/assets/{asset_id}",
|
|
448
|
+
)
|
|
449
|
+
response.raise_for_status()
|
|
450
|
+
result = response.json()
|
|
451
|
+
return result["uploadMechanism"][
|
|
452
|
+
"com.linkedin.digitalmedia.uploading.MediaUploadHttpRequest"
|
|
453
|
+
]["uploadUrl"]
|
|
454
|
+
|
|
455
|
+
try:
|
|
456
|
+
return await _get_url()
|
|
457
|
+
except httpx.HTTPError as e:
|
|
458
|
+
raise MediaUploadError(
|
|
459
|
+
f"Failed to get upload URL: {e}",
|
|
460
|
+
platform="linkedin",
|
|
461
|
+
) from e
|
|
462
|
+
|
|
463
|
+
async def _upload_to_url(
|
|
464
|
+
self,
|
|
465
|
+
upload_url: str,
|
|
466
|
+
file_bytes: bytes,
|
|
467
|
+
file_size: int,
|
|
468
|
+
asset_id: str,
|
|
469
|
+
) -> None:
|
|
470
|
+
"""Upload file bytes to the upload URL."""
|
|
471
|
+
|
|
472
|
+
@retry_async(config=STANDARD_BACKOFF)
|
|
473
|
+
async def _upload() -> None:
|
|
474
|
+
# LinkedIn requires specific headers for upload
|
|
475
|
+
headers = {
|
|
476
|
+
"Content-Type": "application/octet-stream",
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
# Notify upload start
|
|
480
|
+
if self.progress_callback:
|
|
481
|
+
self.progress_callback(
|
|
482
|
+
UploadProgress(asset_id, 0, file_size, "uploading")
|
|
483
|
+
)
|
|
484
|
+
|
|
485
|
+
response = await self.client.put(
|
|
486
|
+
upload_url,
|
|
487
|
+
content=file_bytes,
|
|
488
|
+
headers=headers,
|
|
489
|
+
)
|
|
490
|
+
response.raise_for_status()
|
|
491
|
+
|
|
492
|
+
# Notify upload complete
|
|
493
|
+
if self.progress_callback:
|
|
494
|
+
self.progress_callback(
|
|
495
|
+
UploadProgress(asset_id, file_size, file_size, "uploading")
|
|
496
|
+
)
|
|
497
|
+
|
|
498
|
+
try:
|
|
499
|
+
await _upload()
|
|
500
|
+
except httpx.HTTPError as e:
|
|
501
|
+
raise MediaUploadError(
|
|
502
|
+
f"Failed to upload file: {e}",
|
|
503
|
+
platform="linkedin",
|
|
504
|
+
) from e
|
|
505
|
+
|
|
506
|
+
async def _wait_for_video_processing(
|
|
507
|
+
self,
|
|
508
|
+
asset_id: str,
|
|
509
|
+
*,
|
|
510
|
+
timeout: int = VIDEO_PROCESSING_TIMEOUT,
|
|
511
|
+
check_interval: int = 5,
|
|
512
|
+
) -> None:
|
|
513
|
+
"""Wait for video processing to complete."""
|
|
514
|
+
elapsed = 0
|
|
515
|
+
logger.info(f"Waiting for video {asset_id} to process...")
|
|
516
|
+
|
|
517
|
+
while elapsed < timeout:
|
|
518
|
+
status_data = await self.get_video_status(asset_id)
|
|
519
|
+
status = status_data.get("status")
|
|
520
|
+
|
|
521
|
+
if status in (
|
|
522
|
+
VideoProcessingState.READY.value,
|
|
523
|
+
VideoProcessingState.AVAILABLE.value,
|
|
524
|
+
):
|
|
525
|
+
logger.info(f"Video {asset_id} processing complete")
|
|
526
|
+
return
|
|
527
|
+
|
|
528
|
+
if status == VideoProcessingState.FAILED.value:
|
|
529
|
+
raise MediaUploadError(
|
|
530
|
+
f"Video processing failed for {asset_id}",
|
|
531
|
+
platform="linkedin",
|
|
532
|
+
media_type="video",
|
|
533
|
+
)
|
|
534
|
+
|
|
535
|
+
# Notify progress
|
|
536
|
+
if self.progress_callback:
|
|
537
|
+
progress_pct = min(int((elapsed / timeout) * 90), 90)
|
|
538
|
+
self.progress_callback(
|
|
539
|
+
UploadProgress(asset_id, progress_pct, 100, "processing")
|
|
540
|
+
)
|
|
541
|
+
|
|
542
|
+
await asyncio.sleep(check_interval)
|
|
543
|
+
elapsed += check_interval
|
|
544
|
+
|
|
545
|
+
raise MediaUploadError(
|
|
546
|
+
f"Video processing timeout after {timeout}s",
|
|
547
|
+
platform="linkedin",
|
|
548
|
+
media_type="video",
|
|
549
|
+
)
|