marqetive-lib 0.1.17__py3-none-any.whl → 0.1.20__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/core/base.py +33 -1
- marqetive/core/models.py +2 -0
- marqetive/platforms/instagram/client.py +43 -6
- marqetive/platforms/linkedin/client.py +432 -42
- marqetive/platforms/linkedin/media.py +320 -211
- marqetive/platforms/tiktok/client.py +51 -10
- marqetive/platforms/twitter/client.py +66 -8
- {marqetive_lib-0.1.17.dist-info → marqetive_lib-0.1.20.dist-info}/METADATA +1 -1
- {marqetive_lib-0.1.17.dist-info → marqetive_lib-0.1.20.dist-info}/RECORD +10 -10
- {marqetive_lib-0.1.17.dist-info → marqetive_lib-0.1.20.dist-info}/WHEEL +0 -0
|
@@ -1,13 +1,19 @@
|
|
|
1
1
|
"""LinkedIn media upload manager with support for images, videos, and documents.
|
|
2
2
|
|
|
3
|
-
LinkedIn uses
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
3
|
+
LinkedIn uses the REST API for media uploads (Community Management API):
|
|
4
|
+
- Images API: POST /rest/images?action=initializeUpload
|
|
5
|
+
- Videos API: POST /rest/videos?action=initializeUpload + finalizeUpload
|
|
6
|
+
- Documents API: POST /rest/documents?action=initializeUpload
|
|
7
|
+
|
|
8
|
+
Upload process:
|
|
9
|
+
1. Initialize upload (get upload URL and asset URN)
|
|
10
|
+
2. Upload file to the URL (PUT)
|
|
11
|
+
3. For videos: Finalize upload with ETags from each chunk
|
|
12
|
+
4. For videos: Wait for processing to complete
|
|
7
13
|
|
|
8
14
|
This module supports:
|
|
9
15
|
- Image uploads (single and multiple)
|
|
10
|
-
- Video uploads with processing monitoring
|
|
16
|
+
- Video uploads with chunked upload and processing monitoring
|
|
11
17
|
- Document/PDF uploads
|
|
12
18
|
"""
|
|
13
19
|
|
|
@@ -37,23 +43,44 @@ type ProgressCallback = SyncProgressCallback | AsyncProgressCallback
|
|
|
37
43
|
|
|
38
44
|
logger = logging.getLogger(__name__)
|
|
39
45
|
|
|
40
|
-
# LinkedIn limits
|
|
46
|
+
# LinkedIn limits (per REST API documentation)
|
|
41
47
|
MAX_IMAGE_SIZE = 10 * 1024 * 1024 # 10MB
|
|
42
|
-
MAX_VIDEO_SIZE =
|
|
43
|
-
|
|
44
|
-
|
|
48
|
+
MAX_VIDEO_SIZE = 5 * 1024 * 1024 * 1024 # 5GB (for multi-part uploads)
|
|
49
|
+
MAX_VIDEO_SIZE_SINGLE = 200 * 1024 * 1024 # 200MB (practical limit for single upload)
|
|
50
|
+
MAX_DOCUMENT_SIZE = 100 * 1024 * 1024 # 100MB (per LinkedIn docs)
|
|
51
|
+
MAX_VIDEO_DURATION = 1800 # 30 minutes (per docs: 3 seconds to 30 minutes)
|
|
52
|
+
MAX_DOCUMENT_PAGES = 300 # Maximum pages for documents
|
|
53
|
+
|
|
54
|
+
# Video chunk size for multi-part uploads (per LinkedIn docs: 4MB per part)
|
|
55
|
+
VIDEO_CHUNK_SIZE = 4 * 1024 * 1024 # 4MB (4,194,304 bytes)
|
|
56
|
+
|
|
57
|
+
# Supported document MIME types
|
|
58
|
+
SUPPORTED_DOCUMENT_TYPES = {
|
|
59
|
+
"application/pdf",
|
|
60
|
+
"application/vnd.ms-powerpoint", # PPT
|
|
61
|
+
"application/vnd.openxmlformats-officedocument.presentationml.presentation", # PPTX
|
|
62
|
+
"application/msword", # DOC
|
|
63
|
+
"application/vnd.openxmlformats-officedocument.wordprocessingml.document", # DOCX
|
|
64
|
+
}
|
|
45
65
|
|
|
46
66
|
# Processing timeouts
|
|
47
67
|
VIDEO_PROCESSING_TIMEOUT = 600 # 10 minutes
|
|
48
68
|
|
|
49
69
|
|
|
50
70
|
class VideoProcessingState(str, Enum):
|
|
51
|
-
"""LinkedIn video processing states.
|
|
71
|
+
"""LinkedIn video processing states (per REST Videos API).
|
|
72
|
+
|
|
73
|
+
Status values:
|
|
74
|
+
PROCESSING: Asset processing to generate missing artifacts
|
|
75
|
+
PROCESSING_FAILED: Processing failed (file size, format, internal error)
|
|
76
|
+
AVAILABLE: Ready for use; all required artifacts available
|
|
77
|
+
WAITING_UPLOAD: Waiting for source file upload completion
|
|
78
|
+
"""
|
|
52
79
|
|
|
53
80
|
PROCESSING = "PROCESSING"
|
|
54
|
-
|
|
55
|
-
FAILED = "FAILED"
|
|
81
|
+
PROCESSING_FAILED = "PROCESSING_FAILED"
|
|
56
82
|
AVAILABLE = "AVAILABLE"
|
|
83
|
+
WAITING_UPLOAD = "WAITING_UPLOAD"
|
|
57
84
|
|
|
58
85
|
|
|
59
86
|
@dataclass
|
|
@@ -134,9 +161,8 @@ class LinkedInMediaManager:
|
|
|
134
161
|
self.timeout = timeout
|
|
135
162
|
self.progress_callback = progress_callback
|
|
136
163
|
|
|
137
|
-
# Use
|
|
138
|
-
|
|
139
|
-
self.base_url = "https://api.linkedin.com/v2"
|
|
164
|
+
# Use REST API for media uploads (Community Management API)
|
|
165
|
+
self.base_url = "https://api.linkedin.com/rest"
|
|
140
166
|
self.client = httpx.AsyncClient(
|
|
141
167
|
timeout=httpx.Timeout(timeout),
|
|
142
168
|
headers={
|
|
@@ -199,22 +225,28 @@ class LinkedInMediaManager:
|
|
|
199
225
|
*,
|
|
200
226
|
alt_text: str | None = None, # noqa: ARG002
|
|
201
227
|
) -> MediaAsset:
|
|
202
|
-
"""Upload an image to LinkedIn.
|
|
228
|
+
"""Upload an image to LinkedIn using the REST Images API.
|
|
229
|
+
|
|
230
|
+
Uses the REST API endpoint: POST /rest/images?action=initializeUpload
|
|
231
|
+
https://learn.microsoft.com/en-us/linkedin/marketing/community-management/shares/images-api
|
|
203
232
|
|
|
204
233
|
Args:
|
|
205
234
|
file_path: Path to image file or URL.
|
|
206
|
-
alt_text: Alternative text for accessibility.
|
|
235
|
+
alt_text: Alternative text for accessibility (stored for reference).
|
|
207
236
|
|
|
208
237
|
Returns:
|
|
209
|
-
MediaAsset with
|
|
238
|
+
MediaAsset with image URN (urn:li:image:xxx).
|
|
210
239
|
|
|
211
240
|
Raises:
|
|
212
241
|
MediaUploadError: If upload fails.
|
|
213
242
|
ValidationError: If file is invalid.
|
|
214
243
|
|
|
244
|
+
Supported formats:
|
|
245
|
+
JPG, GIF, PNG (GIF up to 250 frames)
|
|
246
|
+
|
|
215
247
|
Example:
|
|
216
248
|
>>> asset = await manager.upload_image("photo.jpg")
|
|
217
|
-
>>> print(f"
|
|
249
|
+
>>> print(f"Image URN: {asset.asset_id}")
|
|
218
250
|
"""
|
|
219
251
|
# Download if URL
|
|
220
252
|
if file_path.startswith(("http://", "https://")):
|
|
@@ -238,52 +270,76 @@ class LinkedInMediaManager:
|
|
|
238
270
|
platform="linkedin",
|
|
239
271
|
)
|
|
240
272
|
|
|
241
|
-
#
|
|
242
|
-
|
|
243
|
-
"registerUploadRequest": {
|
|
244
|
-
"recipes": ["urn:li:digitalmediaRecipe:feedshare-image"],
|
|
245
|
-
"owner": self.person_urn,
|
|
246
|
-
"serviceRelationships": [
|
|
247
|
-
{
|
|
248
|
-
"relationshipType": "OWNER",
|
|
249
|
-
"identifier": "urn:li:userGeneratedContent",
|
|
250
|
-
}
|
|
251
|
-
],
|
|
252
|
-
}
|
|
253
|
-
}
|
|
273
|
+
# Step 1: Initialize upload using REST Images API
|
|
274
|
+
init_payload = {"initializeUploadRequest": {"owner": self.person_urn}}
|
|
254
275
|
|
|
255
|
-
|
|
276
|
+
try:
|
|
277
|
+
response = await self.client.post(
|
|
278
|
+
f"{self.base_url}/images?action=initializeUpload",
|
|
279
|
+
json=init_payload,
|
|
280
|
+
)
|
|
281
|
+
response.raise_for_status()
|
|
282
|
+
init_result = response.json()
|
|
283
|
+
except httpx.HTTPError as e:
|
|
284
|
+
raise MediaUploadError(
|
|
285
|
+
f"Failed to initialize image upload: {e}",
|
|
286
|
+
platform="linkedin",
|
|
287
|
+
media_type="image",
|
|
288
|
+
) from e
|
|
289
|
+
|
|
290
|
+
# Extract upload URL and image URN from response
|
|
291
|
+
upload_url = init_result["value"]["uploadUrl"]
|
|
292
|
+
image_urn = init_result["value"]["image"]
|
|
256
293
|
|
|
257
294
|
# Notify start
|
|
258
295
|
await self._emit_progress(
|
|
259
296
|
status=ProgressStatus.INITIALIZING,
|
|
260
297
|
progress=0,
|
|
261
298
|
total=100,
|
|
262
|
-
message="Registering upload",
|
|
263
|
-
entity_id=
|
|
299
|
+
message="Registering image upload",
|
|
300
|
+
entity_id=image_urn,
|
|
264
301
|
bytes_uploaded=0,
|
|
265
302
|
total_bytes=file_size,
|
|
266
303
|
)
|
|
267
304
|
|
|
268
|
-
#
|
|
269
|
-
|
|
305
|
+
# Step 2: Upload image binary via PUT
|
|
306
|
+
await self._emit_progress(
|
|
307
|
+
status=ProgressStatus.UPLOADING,
|
|
308
|
+
progress=0,
|
|
309
|
+
total=100,
|
|
310
|
+
message="Uploading image",
|
|
311
|
+
entity_id=image_urn,
|
|
312
|
+
bytes_uploaded=0,
|
|
313
|
+
total_bytes=file_size,
|
|
314
|
+
)
|
|
270
315
|
|
|
271
|
-
|
|
272
|
-
|
|
316
|
+
try:
|
|
317
|
+
upload_response = await self.client.put(
|
|
318
|
+
upload_url,
|
|
319
|
+
content=file_bytes,
|
|
320
|
+
headers={"Content-Type": "application/octet-stream"},
|
|
321
|
+
)
|
|
322
|
+
upload_response.raise_for_status()
|
|
323
|
+
except httpx.HTTPError as e:
|
|
324
|
+
raise MediaUploadError(
|
|
325
|
+
f"Failed to upload image binary: {e}",
|
|
326
|
+
platform="linkedin",
|
|
327
|
+
media_type="image",
|
|
328
|
+
) from e
|
|
273
329
|
|
|
274
330
|
# Notify completion
|
|
275
331
|
await self._emit_progress(
|
|
276
332
|
status=ProgressStatus.COMPLETED,
|
|
277
333
|
progress=100,
|
|
278
334
|
total=100,
|
|
279
|
-
message="
|
|
280
|
-
entity_id=
|
|
335
|
+
message="Image upload completed",
|
|
336
|
+
entity_id=image_urn,
|
|
281
337
|
bytes_uploaded=file_size,
|
|
282
338
|
total_bytes=file_size,
|
|
283
339
|
)
|
|
284
340
|
|
|
285
|
-
logger.info(f"Image uploaded successfully: {
|
|
286
|
-
return MediaAsset(asset_id=
|
|
341
|
+
logger.info(f"Image uploaded successfully: {image_urn}")
|
|
342
|
+
return MediaAsset(asset_id=image_urn, status="AVAILABLE")
|
|
287
343
|
|
|
288
344
|
async def upload_video(
|
|
289
345
|
self,
|
|
@@ -292,26 +348,39 @@ class LinkedInMediaManager:
|
|
|
292
348
|
title: str | None = None, # noqa: ARG002
|
|
293
349
|
wait_for_processing: bool = True,
|
|
294
350
|
) -> MediaAsset:
|
|
295
|
-
"""Upload a video to LinkedIn.
|
|
351
|
+
"""Upload a video to LinkedIn using the REST Videos API.
|
|
352
|
+
|
|
353
|
+
Uses the REST API endpoints:
|
|
354
|
+
- POST /rest/videos?action=initializeUpload
|
|
355
|
+
- PUT {uploadUrl} (for each chunk)
|
|
356
|
+
- POST /rest/videos?action=finalizeUpload
|
|
357
|
+
|
|
358
|
+
https://learn.microsoft.com/en-us/linkedin/marketing/community-management/shares/videos-api
|
|
296
359
|
|
|
297
360
|
Args:
|
|
298
361
|
file_path: Path to video file or URL.
|
|
299
|
-
title: Video title.
|
|
362
|
+
title: Video title (reserved for future use).
|
|
300
363
|
wait_for_processing: Wait for video processing to complete.
|
|
301
364
|
|
|
302
365
|
Returns:
|
|
303
|
-
MediaAsset with
|
|
366
|
+
MediaAsset with video URN (urn:li:video:xxx).
|
|
304
367
|
|
|
305
368
|
Raises:
|
|
306
369
|
MediaUploadError: If upload or processing fails.
|
|
307
370
|
ValidationError: If file is invalid.
|
|
308
371
|
|
|
372
|
+
Specifications:
|
|
373
|
+
- Length: 3 seconds to 30 minutes
|
|
374
|
+
- File size: 75 KB to 5 GB
|
|
375
|
+
- Format: MP4
|
|
376
|
+
- Chunk size: 4 MB per part (for multi-part uploads)
|
|
377
|
+
|
|
309
378
|
Example:
|
|
310
379
|
>>> asset = await manager.upload_video(
|
|
311
380
|
... "video.mp4",
|
|
312
|
-
... title="My Video",
|
|
313
381
|
... wait_for_processing=True
|
|
314
382
|
... )
|
|
383
|
+
>>> print(f"Video URN: {asset.asset_id}")
|
|
315
384
|
"""
|
|
316
385
|
# Download if URL
|
|
317
386
|
if file_path.startswith(("http://", "https://")):
|
|
@@ -335,21 +404,34 @@ class LinkedInMediaManager:
|
|
|
335
404
|
platform="linkedin",
|
|
336
405
|
)
|
|
337
406
|
|
|
338
|
-
#
|
|
339
|
-
|
|
340
|
-
"
|
|
341
|
-
"recipes": ["urn:li:digitalmediaRecipe:feedshare-video"],
|
|
407
|
+
# Step 1: Initialize upload using REST Videos API
|
|
408
|
+
init_payload = {
|
|
409
|
+
"initializeUploadRequest": {
|
|
342
410
|
"owner": self.person_urn,
|
|
343
|
-
"
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
"identifier": "urn:li:userGeneratedContent",
|
|
347
|
-
}
|
|
348
|
-
],
|
|
411
|
+
"fileSizeBytes": file_size,
|
|
412
|
+
"uploadCaptions": False,
|
|
413
|
+
"uploadThumbnail": False,
|
|
349
414
|
}
|
|
350
415
|
}
|
|
351
416
|
|
|
352
|
-
|
|
417
|
+
try:
|
|
418
|
+
response = await self.client.post(
|
|
419
|
+
f"{self.base_url}/videos?action=initializeUpload",
|
|
420
|
+
json=init_payload,
|
|
421
|
+
)
|
|
422
|
+
response.raise_for_status()
|
|
423
|
+
init_result = response.json()
|
|
424
|
+
except httpx.HTTPError as e:
|
|
425
|
+
raise MediaUploadError(
|
|
426
|
+
f"Failed to initialize video upload: {e}",
|
|
427
|
+
platform="linkedin",
|
|
428
|
+
media_type="video",
|
|
429
|
+
) from e
|
|
430
|
+
|
|
431
|
+
# Extract video URN and upload instructions
|
|
432
|
+
video_urn = init_result["value"]["video"]
|
|
433
|
+
upload_instructions = init_result["value"]["uploadInstructions"]
|
|
434
|
+
upload_token = init_result["value"].get("uploadToken", "")
|
|
353
435
|
|
|
354
436
|
# Notify start
|
|
355
437
|
await self._emit_progress(
|
|
@@ -357,20 +439,94 @@ class LinkedInMediaManager:
|
|
|
357
439
|
progress=0,
|
|
358
440
|
total=100,
|
|
359
441
|
message="Registering video upload",
|
|
360
|
-
entity_id=
|
|
442
|
+
entity_id=video_urn,
|
|
361
443
|
bytes_uploaded=0,
|
|
362
444
|
total_bytes=file_size,
|
|
363
445
|
)
|
|
364
446
|
|
|
365
|
-
#
|
|
366
|
-
|
|
447
|
+
# Step 2: Upload video in chunks and collect ETags
|
|
448
|
+
uploaded_part_ids: list[str] = []
|
|
449
|
+
total_chunks = len(upload_instructions)
|
|
450
|
+
bytes_uploaded = 0
|
|
451
|
+
|
|
452
|
+
for i, instruction in enumerate(upload_instructions):
|
|
453
|
+
upload_url = instruction["uploadUrl"]
|
|
454
|
+
first_byte = instruction["firstByte"]
|
|
455
|
+
last_byte = instruction["lastByte"]
|
|
367
456
|
|
|
368
|
-
|
|
369
|
-
|
|
457
|
+
# Extract chunk from file bytes
|
|
458
|
+
chunk = file_bytes[first_byte : last_byte + 1]
|
|
459
|
+
chunk_size = len(chunk)
|
|
370
460
|
|
|
371
|
-
|
|
461
|
+
# Notify progress
|
|
462
|
+
progress_pct = int((i / total_chunks) * 80) # 80% for upload
|
|
463
|
+
await self._emit_progress(
|
|
464
|
+
status=ProgressStatus.UPLOADING,
|
|
465
|
+
progress=progress_pct,
|
|
466
|
+
total=100,
|
|
467
|
+
message=f"Uploading chunk {i + 1}/{total_chunks}",
|
|
468
|
+
entity_id=video_urn,
|
|
469
|
+
bytes_uploaded=bytes_uploaded,
|
|
470
|
+
total_bytes=file_size,
|
|
471
|
+
)
|
|
472
|
+
|
|
473
|
+
try:
|
|
474
|
+
upload_response = await self.client.put(
|
|
475
|
+
upload_url,
|
|
476
|
+
content=chunk,
|
|
477
|
+
headers={"Content-Type": "application/octet-stream"},
|
|
478
|
+
)
|
|
479
|
+
upload_response.raise_for_status()
|
|
480
|
+
|
|
481
|
+
# Get ETag from response headers (required for finalization)
|
|
482
|
+
etag = upload_response.headers.get("etag")
|
|
483
|
+
if etag:
|
|
484
|
+
uploaded_part_ids.append(etag)
|
|
485
|
+
|
|
486
|
+
bytes_uploaded += chunk_size
|
|
487
|
+
|
|
488
|
+
except httpx.HTTPError as e:
|
|
489
|
+
raise MediaUploadError(
|
|
490
|
+
f"Failed to upload video chunk {i + 1}: {e}",
|
|
491
|
+
platform="linkedin",
|
|
492
|
+
media_type="video",
|
|
493
|
+
) from e
|
|
494
|
+
|
|
495
|
+
# Step 3: Finalize upload
|
|
496
|
+
await self._emit_progress(
|
|
497
|
+
status=ProgressStatus.UPLOADING,
|
|
498
|
+
progress=85,
|
|
499
|
+
total=100,
|
|
500
|
+
message="Finalizing video upload",
|
|
501
|
+
entity_id=video_urn,
|
|
502
|
+
bytes_uploaded=file_size,
|
|
503
|
+
total_bytes=file_size,
|
|
504
|
+
)
|
|
505
|
+
|
|
506
|
+
finalize_payload = {
|
|
507
|
+
"finalizeUploadRequest": {
|
|
508
|
+
"video": video_urn,
|
|
509
|
+
"uploadToken": upload_token,
|
|
510
|
+
"uploadedPartIds": uploaded_part_ids,
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
try:
|
|
515
|
+
finalize_response = await self.client.post(
|
|
516
|
+
f"{self.base_url}/videos?action=finalizeUpload",
|
|
517
|
+
json=finalize_payload,
|
|
518
|
+
)
|
|
519
|
+
finalize_response.raise_for_status()
|
|
520
|
+
except httpx.HTTPError as e:
|
|
521
|
+
raise MediaUploadError(
|
|
522
|
+
f"Failed to finalize video upload: {e}",
|
|
523
|
+
platform="linkedin",
|
|
524
|
+
media_type="video",
|
|
525
|
+
) from e
|
|
526
|
+
|
|
527
|
+
# Step 4: Wait for processing if requested
|
|
372
528
|
if wait_for_processing:
|
|
373
|
-
await self._wait_for_video_processing(
|
|
529
|
+
await self._wait_for_video_processing(video_urn)
|
|
374
530
|
|
|
375
531
|
# Notify completion
|
|
376
532
|
final_status = (
|
|
@@ -385,14 +541,15 @@ class LinkedInMediaManager:
|
|
|
385
541
|
message=(
|
|
386
542
|
"Video upload completed" if wait_for_processing else "Video processing"
|
|
387
543
|
),
|
|
388
|
-
entity_id=
|
|
544
|
+
entity_id=video_urn,
|
|
389
545
|
bytes_uploaded=file_size,
|
|
390
546
|
total_bytes=file_size,
|
|
391
547
|
)
|
|
392
548
|
|
|
393
|
-
logger.info(f"Video uploaded successfully: {
|
|
549
|
+
logger.info(f"Video uploaded successfully: {video_urn}")
|
|
394
550
|
return MediaAsset(
|
|
395
|
-
asset_id=
|
|
551
|
+
asset_id=video_urn,
|
|
552
|
+
status="AVAILABLE" if wait_for_processing else "PROCESSING",
|
|
396
553
|
)
|
|
397
554
|
|
|
398
555
|
async def upload_document(
|
|
@@ -401,19 +558,25 @@ class LinkedInMediaManager:
|
|
|
401
558
|
*,
|
|
402
559
|
title: str | None = None, # noqa: ARG002
|
|
403
560
|
) -> MediaAsset:
|
|
404
|
-
"""Upload a document
|
|
561
|
+
"""Upload a document to LinkedIn using the Documents API.
|
|
562
|
+
|
|
563
|
+
Uses the REST API endpoint /rest/documents as per LinkedIn documentation:
|
|
564
|
+
https://learn.microsoft.com/en-us/linkedin/marketing/community-management/shares/documents-api
|
|
405
565
|
|
|
406
566
|
Args:
|
|
407
567
|
file_path: Path to document file or URL.
|
|
408
|
-
title: Document title.
|
|
568
|
+
title: Document title (currently unused, reserved for future use).
|
|
409
569
|
|
|
410
570
|
Returns:
|
|
411
|
-
MediaAsset with
|
|
571
|
+
MediaAsset with document URN (urn:li:document:xxx).
|
|
412
572
|
|
|
413
573
|
Raises:
|
|
414
574
|
MediaUploadError: If upload fails.
|
|
415
575
|
ValidationError: If file is invalid.
|
|
416
576
|
|
|
577
|
+
Supported formats:
|
|
578
|
+
PDF, PPT, PPTX, DOC, DOCX (max 100MB, 300 pages)
|
|
579
|
+
|
|
417
580
|
Example:
|
|
418
581
|
>>> asset = await manager.upload_document(
|
|
419
582
|
... "presentation.pdf",
|
|
@@ -424,11 +587,12 @@ class LinkedInMediaManager:
|
|
|
424
587
|
if file_path.startswith(("http://", "https://")):
|
|
425
588
|
file_path = await download_file(file_path)
|
|
426
589
|
|
|
427
|
-
# Validate
|
|
590
|
+
# Validate MIME type
|
|
428
591
|
mime_type = detect_mime_type(file_path)
|
|
429
|
-
if mime_type
|
|
592
|
+
if mime_type not in SUPPORTED_DOCUMENT_TYPES:
|
|
430
593
|
raise ValidationError(
|
|
431
|
-
f"
|
|
594
|
+
f"Unsupported document type: {mime_type}. "
|
|
595
|
+
f"Supported: PDF, PPT, PPTX, DOC, DOCX",
|
|
432
596
|
platform="linkedin",
|
|
433
597
|
)
|
|
434
598
|
|
|
@@ -442,21 +606,27 @@ class LinkedInMediaManager:
|
|
|
442
606
|
platform="linkedin",
|
|
443
607
|
)
|
|
444
608
|
|
|
445
|
-
#
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
"recipes": ["urn:li:digitalmediaRecipe:feedshare-document"],
|
|
449
|
-
"owner": self.person_urn,
|
|
450
|
-
"serviceRelationships": [
|
|
451
|
-
{
|
|
452
|
-
"relationshipType": "OWNER",
|
|
453
|
-
"identifier": "urn:li:userGeneratedContent",
|
|
454
|
-
}
|
|
455
|
-
],
|
|
456
|
-
}
|
|
457
|
-
}
|
|
609
|
+
# Step 1: Initialize upload using REST documents API
|
|
610
|
+
# Per docs: POST /rest/documents?action=initializeUpload
|
|
611
|
+
init_payload = {"initializeUploadRequest": {"owner": self.person_urn}}
|
|
458
612
|
|
|
459
|
-
|
|
613
|
+
try:
|
|
614
|
+
response = await self.client.post(
|
|
615
|
+
f"{self.base_url}/documents?action=initializeUpload",
|
|
616
|
+
json=init_payload,
|
|
617
|
+
)
|
|
618
|
+
response.raise_for_status()
|
|
619
|
+
init_result = response.json()
|
|
620
|
+
except httpx.HTTPError as e:
|
|
621
|
+
raise MediaUploadError(
|
|
622
|
+
f"Failed to initialize document upload: {e}",
|
|
623
|
+
platform="linkedin",
|
|
624
|
+
media_type="document",
|
|
625
|
+
) from e
|
|
626
|
+
|
|
627
|
+
# Extract upload URL and document URN from response
|
|
628
|
+
upload_url = init_result["value"]["uploadUrl"]
|
|
629
|
+
document_urn = init_result["value"]["document"]
|
|
460
630
|
|
|
461
631
|
# Notify start
|
|
462
632
|
await self._emit_progress(
|
|
@@ -464,16 +634,35 @@ class LinkedInMediaManager:
|
|
|
464
634
|
progress=0,
|
|
465
635
|
total=100,
|
|
466
636
|
message="Registering document upload",
|
|
467
|
-
entity_id=
|
|
637
|
+
entity_id=document_urn,
|
|
468
638
|
bytes_uploaded=0,
|
|
469
639
|
total_bytes=file_size,
|
|
470
640
|
)
|
|
471
641
|
|
|
472
|
-
#
|
|
473
|
-
|
|
642
|
+
# Step 2: Upload document binary via PUT
|
|
643
|
+
await self._emit_progress(
|
|
644
|
+
status=ProgressStatus.UPLOADING,
|
|
645
|
+
progress=0,
|
|
646
|
+
total=100,
|
|
647
|
+
message="Uploading document",
|
|
648
|
+
entity_id=document_urn,
|
|
649
|
+
bytes_uploaded=0,
|
|
650
|
+
total_bytes=file_size,
|
|
651
|
+
)
|
|
474
652
|
|
|
475
|
-
|
|
476
|
-
|
|
653
|
+
try:
|
|
654
|
+
upload_response = await self.client.put(
|
|
655
|
+
upload_url,
|
|
656
|
+
content=file_bytes,
|
|
657
|
+
headers={"Content-Type": "application/octet-stream"},
|
|
658
|
+
)
|
|
659
|
+
upload_response.raise_for_status()
|
|
660
|
+
except httpx.HTTPError as e:
|
|
661
|
+
raise MediaUploadError(
|
|
662
|
+
f"Failed to upload document binary: {e}",
|
|
663
|
+
platform="linkedin",
|
|
664
|
+
media_type="document",
|
|
665
|
+
) from e
|
|
477
666
|
|
|
478
667
|
# Notify completion
|
|
479
668
|
await self._emit_progress(
|
|
@@ -481,171 +670,91 @@ class LinkedInMediaManager:
|
|
|
481
670
|
progress=100,
|
|
482
671
|
total=100,
|
|
483
672
|
message="Document upload completed",
|
|
484
|
-
entity_id=
|
|
673
|
+
entity_id=document_urn,
|
|
485
674
|
bytes_uploaded=file_size,
|
|
486
675
|
total_bytes=file_size,
|
|
487
676
|
)
|
|
488
677
|
|
|
489
|
-
logger.info(f"Document uploaded successfully: {
|
|
490
|
-
return MediaAsset(asset_id=
|
|
678
|
+
logger.info(f"Document uploaded successfully: {document_urn}")
|
|
679
|
+
return MediaAsset(asset_id=document_urn, status="AVAILABLE")
|
|
680
|
+
|
|
681
|
+
async def get_video_status(self, video_urn: str) -> dict[str, Any]:
|
|
682
|
+
"""Get processing status of a video using the REST Videos API.
|
|
491
683
|
|
|
492
|
-
|
|
493
|
-
"""Get processing status of a video asset.
|
|
684
|
+
Uses the REST API endpoint: GET /rest/videos/{videoUrn}
|
|
494
685
|
|
|
495
686
|
Args:
|
|
496
|
-
|
|
687
|
+
video_urn: LinkedIn video URN (urn:li:video:xxx).
|
|
497
688
|
|
|
498
689
|
Returns:
|
|
499
|
-
Dictionary with video status information
|
|
690
|
+
Dictionary with video status information including:
|
|
691
|
+
- status: PROCESSING, PROCESSING_FAILED, AVAILABLE, WAITING_UPLOAD
|
|
692
|
+
- downloadUrl: URL to download/view the video (when AVAILABLE)
|
|
693
|
+
- duration: Video length in milliseconds
|
|
694
|
+
- aspectRatioWidth/Height: Video dimensions
|
|
500
695
|
|
|
501
696
|
Example:
|
|
502
|
-
>>> status = await manager.get_video_status(
|
|
697
|
+
>>> status = await manager.get_video_status("urn:li:video:C5505AQH...")
|
|
503
698
|
>>> print(f"Status: {status['status']}")
|
|
504
699
|
"""
|
|
700
|
+
from urllib.parse import quote
|
|
701
|
+
|
|
702
|
+
encoded_urn = quote(video_urn, safe="")
|
|
505
703
|
|
|
506
704
|
@retry_async(config=STANDARD_BACKOFF)
|
|
507
705
|
async def _get_status() -> dict[str, Any]:
|
|
508
706
|
response = await self.client.get(
|
|
509
|
-
f"{self.base_url}/
|
|
707
|
+
f"{self.base_url}/videos/{encoded_urn}",
|
|
510
708
|
)
|
|
511
709
|
response.raise_for_status()
|
|
512
710
|
return response.json()
|
|
513
711
|
|
|
514
712
|
return await _get_status()
|
|
515
713
|
|
|
516
|
-
async def _register_upload(self, register_data: dict[str, Any]) -> str:
|
|
517
|
-
"""Register an upload and get asset ID."""
|
|
518
|
-
|
|
519
|
-
@retry_async(config=STANDARD_BACKOFF)
|
|
520
|
-
async def _register() -> str:
|
|
521
|
-
response = await self.client.post(
|
|
522
|
-
f"{self.base_url}/assets?action=registerUpload",
|
|
523
|
-
json=register_data,
|
|
524
|
-
)
|
|
525
|
-
response.raise_for_status()
|
|
526
|
-
result = response.json()
|
|
527
|
-
return result["value"]["asset"]
|
|
528
|
-
|
|
529
|
-
try:
|
|
530
|
-
return await _register()
|
|
531
|
-
except httpx.HTTPError as e:
|
|
532
|
-
raise MediaUploadError(
|
|
533
|
-
f"Failed to register upload: {e}",
|
|
534
|
-
platform="linkedin",
|
|
535
|
-
) from e
|
|
536
|
-
|
|
537
|
-
async def _get_upload_url(self, asset_id: str) -> str:
|
|
538
|
-
"""Get upload URL for an asset."""
|
|
539
|
-
|
|
540
|
-
@retry_async(config=STANDARD_BACKOFF)
|
|
541
|
-
async def _get_url() -> str:
|
|
542
|
-
response = await self.client.get(
|
|
543
|
-
f"{self.base_url}/assets/{asset_id}",
|
|
544
|
-
)
|
|
545
|
-
response.raise_for_status()
|
|
546
|
-
result = response.json()
|
|
547
|
-
return result["uploadMechanism"][
|
|
548
|
-
"com.linkedin.digitalmedia.uploading.MediaUploadHttpRequest"
|
|
549
|
-
]["uploadUrl"]
|
|
550
|
-
|
|
551
|
-
try:
|
|
552
|
-
return await _get_url()
|
|
553
|
-
except httpx.HTTPError as e:
|
|
554
|
-
raise MediaUploadError(
|
|
555
|
-
f"Failed to get upload URL: {e}",
|
|
556
|
-
platform="linkedin",
|
|
557
|
-
) from e
|
|
558
|
-
|
|
559
|
-
async def _upload_to_url(
|
|
560
|
-
self,
|
|
561
|
-
upload_url: str,
|
|
562
|
-
file_bytes: bytes,
|
|
563
|
-
file_size: int,
|
|
564
|
-
asset_id: str,
|
|
565
|
-
) -> None:
|
|
566
|
-
"""Upload file bytes to the upload URL."""
|
|
567
|
-
|
|
568
|
-
@retry_async(config=STANDARD_BACKOFF)
|
|
569
|
-
async def _upload() -> None:
|
|
570
|
-
# LinkedIn requires specific headers for upload
|
|
571
|
-
headers = {
|
|
572
|
-
"Content-Type": "application/octet-stream",
|
|
573
|
-
}
|
|
574
|
-
|
|
575
|
-
# Notify upload start
|
|
576
|
-
await self._emit_progress(
|
|
577
|
-
status=ProgressStatus.UPLOADING,
|
|
578
|
-
progress=0,
|
|
579
|
-
total=100,
|
|
580
|
-
message="Uploading file",
|
|
581
|
-
entity_id=asset_id,
|
|
582
|
-
bytes_uploaded=0,
|
|
583
|
-
total_bytes=file_size,
|
|
584
|
-
)
|
|
585
|
-
|
|
586
|
-
response = await self.client.put(
|
|
587
|
-
upload_url,
|
|
588
|
-
content=file_bytes,
|
|
589
|
-
headers=headers,
|
|
590
|
-
)
|
|
591
|
-
response.raise_for_status()
|
|
592
|
-
|
|
593
|
-
# Notify upload complete
|
|
594
|
-
await self._emit_progress(
|
|
595
|
-
status=ProgressStatus.UPLOADING,
|
|
596
|
-
progress=100,
|
|
597
|
-
total=100,
|
|
598
|
-
message="File uploaded",
|
|
599
|
-
entity_id=asset_id,
|
|
600
|
-
bytes_uploaded=file_size,
|
|
601
|
-
total_bytes=file_size,
|
|
602
|
-
)
|
|
603
|
-
|
|
604
|
-
try:
|
|
605
|
-
await _upload()
|
|
606
|
-
except httpx.HTTPError as e:
|
|
607
|
-
raise MediaUploadError(
|
|
608
|
-
f"Failed to upload file: {e}",
|
|
609
|
-
platform="linkedin",
|
|
610
|
-
) from e
|
|
611
|
-
|
|
612
714
|
async def _wait_for_video_processing(
|
|
613
715
|
self,
|
|
614
|
-
|
|
716
|
+
video_urn: str,
|
|
615
717
|
*,
|
|
616
718
|
timeout: int = VIDEO_PROCESSING_TIMEOUT,
|
|
617
719
|
check_interval: int = 5,
|
|
618
720
|
) -> None:
|
|
619
|
-
"""Wait for video processing to complete.
|
|
721
|
+
"""Wait for video processing to complete using the REST Videos API.
|
|
722
|
+
|
|
723
|
+
Args:
|
|
724
|
+
video_urn: LinkedIn video URN (urn:li:video:xxx).
|
|
725
|
+
timeout: Maximum time to wait in seconds.
|
|
726
|
+
check_interval: Time between status checks in seconds.
|
|
727
|
+
|
|
728
|
+
Raises:
|
|
729
|
+
MediaUploadError: If processing fails or times out.
|
|
730
|
+
"""
|
|
620
731
|
elapsed = 0
|
|
621
|
-
logger.info(f"Waiting for video {
|
|
732
|
+
logger.info(f"Waiting for video {video_urn} to process...")
|
|
622
733
|
|
|
623
734
|
while elapsed < timeout:
|
|
624
|
-
status_data = await self.get_video_status(
|
|
735
|
+
status_data = await self.get_video_status(video_urn)
|
|
625
736
|
status = status_data.get("status")
|
|
626
737
|
|
|
627
|
-
if status
|
|
628
|
-
|
|
629
|
-
VideoProcessingState.AVAILABLE.value,
|
|
630
|
-
):
|
|
631
|
-
logger.info(f"Video {asset_id} processing complete")
|
|
738
|
+
if status == VideoProcessingState.AVAILABLE.value:
|
|
739
|
+
logger.info(f"Video {video_urn} processing complete")
|
|
632
740
|
return
|
|
633
741
|
|
|
634
|
-
if status == VideoProcessingState.
|
|
742
|
+
if status == VideoProcessingState.PROCESSING_FAILED.value:
|
|
743
|
+
failure_reason = status_data.get("processingFailureReason", "Unknown")
|
|
635
744
|
raise MediaUploadError(
|
|
636
|
-
f"Video processing failed for {
|
|
745
|
+
f"Video processing failed for {video_urn}: {failure_reason}",
|
|
637
746
|
platform="linkedin",
|
|
638
747
|
media_type="video",
|
|
639
748
|
)
|
|
640
749
|
|
|
641
750
|
# Notify progress
|
|
642
|
-
progress_pct = min(int((elapsed / timeout) * 90),
|
|
751
|
+
progress_pct = min(int((elapsed / timeout) * 90) + 85, 99)
|
|
643
752
|
await self._emit_progress(
|
|
644
753
|
status=ProgressStatus.PROCESSING,
|
|
645
754
|
progress=progress_pct,
|
|
646
755
|
total=100,
|
|
647
756
|
message=f"Processing video ({status})",
|
|
648
|
-
entity_id=
|
|
757
|
+
entity_id=video_urn,
|
|
649
758
|
)
|
|
650
759
|
|
|
651
760
|
await asyncio.sleep(check_interval)
|