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.
@@ -1,13 +1,19 @@
1
1
  """LinkedIn media upload manager with support for images, videos, and documents.
2
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)
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 = 200 * 1024 * 1024 # 200MB
43
- MAX_DOCUMENT_SIZE = 10 * 1024 * 1024 # 10MB
44
- MAX_VIDEO_DURATION = 600 # 10 minutes
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
- READY = "READY"
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 v2 API for media uploads (still uses assets endpoint)
138
- # Note: Media registration still uses v2 API, not REST API
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 asset ID.
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"Asset ID: {asset.asset_id}")
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
- # Register upload
242
- register_data = {
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
- asset_id = await self._register_upload(register_data)
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=asset_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
- # Get upload URL
269
- upload_url = await self._get_upload_url(asset_id)
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
- # Upload file
272
- await self._upload_to_url(upload_url, file_bytes, file_size, asset_id)
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="Upload completed",
280
- entity_id=asset_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: {asset_id}")
286
- return MediaAsset(asset_id=asset_id, status="READY")
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 asset ID.
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
- # Register upload
339
- register_data = {
340
- "registerUploadRequest": {
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
- "serviceRelationships": [
344
- {
345
- "relationshipType": "OWNER",
346
- "identifier": "urn:li:userGeneratedContent",
347
- }
348
- ],
411
+ "fileSizeBytes": file_size,
412
+ "uploadCaptions": False,
413
+ "uploadThumbnail": False,
349
414
  }
350
415
  }
351
416
 
352
- asset_id = await self._register_upload(register_data)
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=asset_id,
442
+ entity_id=video_urn,
361
443
  bytes_uploaded=0,
362
444
  total_bytes=file_size,
363
445
  )
364
446
 
365
- # Get upload URL
366
- upload_url = await self._get_upload_url(asset_id)
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
- # Upload file
369
- await self._upload_to_url(upload_url, file_bytes, file_size, asset_id)
457
+ # Extract chunk from file bytes
458
+ chunk = file_bytes[first_byte : last_byte + 1]
459
+ chunk_size = len(chunk)
370
460
 
371
- # Wait for processing if requested
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(asset_id)
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=asset_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: {asset_id}")
549
+ logger.info(f"Video uploaded successfully: {video_urn}")
394
550
  return MediaAsset(
395
- asset_id=asset_id, status="READY" if wait_for_processing else "PROCESSING"
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/PDF to LinkedIn.
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 asset ID.
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 != "application/pdf":
592
+ if mime_type not in SUPPORTED_DOCUMENT_TYPES:
430
593
  raise ValidationError(
431
- f"Only PDF documents are supported. Got: {mime_type}",
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
- # Register upload
446
- register_data = {
447
- "registerUploadRequest": {
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
- asset_id = await self._register_upload(register_data)
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=asset_id,
637
+ entity_id=document_urn,
468
638
  bytes_uploaded=0,
469
639
  total_bytes=file_size,
470
640
  )
471
641
 
472
- # Get upload URL
473
- upload_url = await self._get_upload_url(asset_id)
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
- # Upload file
476
- await self._upload_to_url(upload_url, file_bytes, file_size, asset_id)
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=asset_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: {asset_id}")
490
- return MediaAsset(asset_id=asset_id, status="READY")
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
- async def get_video_status(self, asset_id: str) -> dict[str, Any]:
493
- """Get processing status of a video asset.
684
+ Uses the REST API endpoint: GET /rest/videos/{videoUrn}
494
685
 
495
686
  Args:
496
- asset_id: LinkedIn asset URN.
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(asset_id)
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}/assets/{asset_id}",
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
- asset_id: str,
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 {asset_id} to process...")
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(asset_id)
735
+ status_data = await self.get_video_status(video_urn)
625
736
  status = status_data.get("status")
626
737
 
627
- if status in (
628
- VideoProcessingState.READY.value,
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.FAILED.value:
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 {asset_id}",
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), 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=asset_id,
757
+ entity_id=video_urn,
649
758
  )
650
759
 
651
760
  await asyncio.sleep(check_interval)