marqetive-lib 0.1.17__py3-none-any.whl → 0.1.18__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.
@@ -46,6 +46,24 @@ from marqetive.platforms.linkedin.models import (
46
46
  SocialMetadata,
47
47
  )
48
48
 
49
+ # Valid CTA (Call-To-Action) labels per LinkedIn API documentation
50
+ # Note: BUY_NOW and SHOP_NOW require API version 202504 or later
51
+ VALID_CTA_LABELS = frozenset({
52
+ "APPLY",
53
+ "DOWNLOAD",
54
+ "VIEW_QUOTE",
55
+ "LEARN_MORE",
56
+ "SIGN_UP",
57
+ "SUBSCRIBE",
58
+ "REGISTER",
59
+ "JOIN",
60
+ "ATTEND",
61
+ "REQUEST_DEMO",
62
+ "SEE_MORE",
63
+ "BUY_NOW", # Requires API version 202504+
64
+ "SHOP_NOW", # Requires API version 202504+
65
+ })
66
+
49
67
 
50
68
  class LinkedInClient(SocialMediaPlatform):
51
69
  """LinkedIn API client using the Community Management API.
@@ -307,29 +325,111 @@ class LinkedInClient(SocialMediaPlatform):
307
325
  """Create and publish a LinkedIn post.
308
326
 
309
327
  Uses the Community Management API to create posts on personal profiles
310
- or organization pages.
328
+ or organization pages. Supports text, images, videos, documents,
329
+ multi-image posts, articles, and Direct Sponsored Content (dark posts).
311
330
 
312
331
  Args:
313
- request: Post creation request. Use additional_data for LinkedIn-specific
314
- options like visibility, distribution, and call-to-action.
332
+ request: Post creation request with the following fields:
333
+ - content: Post text (max 3000 chars). Supports mentions and hashtags.
334
+ - link: URL for article posts.
335
+ - media_ids: List of media URNs (images, videos, documents).
336
+ - additional_data: LinkedIn-specific options (see below).
337
+
338
+ Additional Data Options:
339
+ Basic options:
340
+ - visibility: "PUBLIC" (default), "CONNECTIONS", "LOGGED_IN"
341
+ - feed_distribution: "MAIN_FEED" (default), "NONE" (for dark posts)
342
+ - disable_reshare: bool (default False)
343
+
344
+ Call-to-action:
345
+ - call_to_action: CTA label (APPLY, DOWNLOAD, VIEW_QUOTE, LEARN_MORE,
346
+ SIGN_UP, SUBSCRIBE, REGISTER, JOIN, ATTEND, REQUEST_DEMO,
347
+ SEE_MORE, BUY_NOW, SHOP_NOW)
348
+ - landing_page: URL for CTA button
349
+
350
+ Media options:
351
+ - media_title: Title for single media
352
+ - media_alt_text: Alt text for single media
353
+ - media_alt_texts: List of alt texts for multi-image posts
354
+
355
+ Article options:
356
+ - article_title: Title for link preview
357
+ - article_description: Description for link preview
358
+ - article_thumbnail: Image URN for article thumbnail
359
+
360
+ Targeting (for targeted posts):
361
+ - target_entities: List of targeting criteria
362
+
363
+ Direct Sponsored Content (dark posts):
364
+ - ad_context: Dict with DSC configuration:
365
+ - is_dsc: True for dark posts
366
+ - dsc_ad_type: VIDEO, STANDARD, CAROUSEL, JOB_POSTING,
367
+ NATIVE_DOCUMENT, EVENT
368
+ - dsc_status: ACTIVE, ARCHIVED
369
+ - dsc_ad_account: Sponsored account URN
370
+ - dsc_name: Display name for the DSC
371
+
372
+ Mentions and Hashtags:
373
+ Use these formats in the content field:
374
+ - Organization mention: @[Display Name](urn:li:organization:12345)
375
+ - Person mention: @[Jane Smith](urn:li:person:abc123)
376
+ - Hashtag: #coding (plain text, auto-formatted by LinkedIn)
377
+
378
+ Note: Organization names in mentions must match exactly (case-sensitive).
315
379
 
316
380
  Returns:
317
381
  Created Post object.
318
382
 
319
383
  Raises:
320
- ValidationError: If request is invalid.
384
+ ValidationError: If request is invalid or CTA label is invalid.
321
385
  MediaUploadError: If media upload fails.
322
386
 
323
387
  Example:
388
+ >>> # Text post with link and CTA
324
389
  >>> request = PostCreateRequest(
325
390
  ... content="Check out our new product!",
326
391
  ... link="https://example.com/product",
327
392
  ... additional_data={
328
393
  ... "visibility": "PUBLIC",
329
- ... "call_to_action": "LEARN_MORE"
394
+ ... "call_to_action": "LEARN_MORE",
395
+ ... "landing_page": "https://example.com/signup"
396
+ ... }
397
+ ... )
398
+ >>> post = await client.create_post(request)
399
+ >>>
400
+ >>> # Multi-image post
401
+ >>> img1 = await client.upload_image("photo1.jpg")
402
+ >>> img2 = await client.upload_image("photo2.jpg")
403
+ >>> request = PostCreateRequest(
404
+ ... content="Check out these photos!",
405
+ ... media_ids=[img1.asset_id, img2.asset_id],
406
+ ... additional_data={
407
+ ... "media_alt_texts": ["First photo", "Second photo"]
408
+ ... }
409
+ ... )
410
+ >>> post = await client.create_post(request)
411
+ >>>
412
+ >>> # Dark post (Direct Sponsored Content)
413
+ >>> request = PostCreateRequest(
414
+ ... content="Sponsored content",
415
+ ... media_ids=[video_asset.asset_id],
416
+ ... additional_data={
417
+ ... "ad_context": {
418
+ ... "is_dsc": True,
419
+ ... "dsc_ad_type": "VIDEO",
420
+ ... "dsc_status": "ACTIVE",
421
+ ... "dsc_ad_account": "urn:li:sponsoredAccount:123"
422
+ ... }
330
423
  ... }
331
424
  ... )
332
425
  >>> post = await client.create_post(request)
426
+ >>>
427
+ >>> # Post with mentions and hashtags
428
+ >>> request = PostCreateRequest(
429
+ ... content="Excited to announce our partnership with "
430
+ ... "@[LinkedIn](urn:li:organization:1337)! #exciting #partnership"
431
+ ... )
432
+ >>> post = await client.create_post(request)
333
433
  """
334
434
  if not self.api_client:
335
435
  raise RuntimeError("Client must be used as async context manager")
@@ -370,23 +470,37 @@ class LinkedInClient(SocialMediaPlatform):
370
470
  ),
371
471
  }
372
472
 
373
- # Add media content if provided
473
+ # Add media content if provided (images, videos, or documents)
374
474
  if request.media_ids:
375
- # Determine media type from URN prefix
376
- media_id = request.media_ids[0]
377
- post_payload["content"] = {
378
- "media": {
379
- "id": media_id,
380
- "title": request.additional_data.get("media_title"),
381
- "altText": request.additional_data.get("media_alt_text"),
475
+ if len(request.media_ids) > 1:
476
+ # Multi-image post (organic only, not for sponsored content)
477
+ # Per docs: use "multiImage" content type with array of images
478
+ images = []
479
+ for media_id in request.media_ids:
480
+ image_entry: dict[str, Any] = {"id": media_id}
481
+ # Alt text can be provided per image via additional_data
482
+ alt_texts = request.additional_data.get("media_alt_texts", [])
483
+ if alt_texts and len(alt_texts) > len(images):
484
+ image_entry["altText"] = alt_texts[len(images)]
485
+ images.append(image_entry)
486
+
487
+ post_payload["content"] = {"multiImage": {"images": images}}
488
+ else:
489
+ # Single media (image, video, or document)
490
+ media_id = request.media_ids[0]
491
+ post_payload["content"] = {
492
+ "media": {
493
+ "id": media_id,
494
+ "title": request.additional_data.get("media_title"),
495
+ "altText": request.additional_data.get("media_alt_text"),
496
+ }
497
+ }
498
+ # Remove None values
499
+ post_payload["content"]["media"] = {
500
+ k: v
501
+ for k, v in post_payload["content"]["media"].items()
502
+ if v is not None
382
503
  }
383
- }
384
- # Remove None values
385
- post_payload["content"]["media"] = {
386
- k: v
387
- for k, v in post_payload["content"]["media"].items()
388
- if v is not None
389
- }
390
504
 
391
505
  # Add article/link if provided (and no media)
392
506
  elif request.link:
@@ -411,10 +525,40 @@ class LinkedInClient(SocialMediaPlatform):
411
525
 
412
526
  # Add call-to-action if provided
413
527
  if cta := request.additional_data.get("call_to_action"):
414
- post_payload["contentCallToActionLabel"] = cta
528
+ cta_upper = cta.upper()
529
+ if cta_upper not in VALID_CTA_LABELS:
530
+ raise ValidationError(
531
+ f"Invalid call_to_action: '{cta}'. "
532
+ f"Valid values: {', '.join(sorted(VALID_CTA_LABELS))}",
533
+ platform=self.platform_name,
534
+ field="call_to_action",
535
+ )
536
+ post_payload["contentCallToActionLabel"] = cta_upper
415
537
  if landing_page := request.additional_data.get("landing_page"):
416
538
  post_payload["contentLandingPage"] = landing_page
417
539
 
540
+ # Add adContext for Direct Sponsored Content (DSC) / dark posts
541
+ # Dark posts don't appear on company page but can be used in ad campaigns
542
+ if ad_context := request.additional_data.get("ad_context"):
543
+ post_payload["adContext"] = {}
544
+ if ad_context.get("is_dsc"):
545
+ post_payload["adContext"]["isDsc"] = True
546
+ if dsc_ad_type := ad_context.get("dsc_ad_type"):
547
+ # Valid types: VIDEO, STANDARD, CAROUSEL, JOB_POSTING,
548
+ # NATIVE_DOCUMENT, EVENT
549
+ post_payload["adContext"]["dscAdType"] = dsc_ad_type
550
+ if dsc_status := ad_context.get("dsc_status"):
551
+ # Valid values: ACTIVE, ARCHIVED
552
+ post_payload["adContext"]["dscStatus"] = dsc_status
553
+ if dsc_ad_account := ad_context.get("dsc_ad_account"):
554
+ post_payload["adContext"]["dscAdAccount"] = dsc_ad_account
555
+ if dsc_name := ad_context.get("dsc_name"):
556
+ post_payload["adContext"]["dscName"] = dsc_name
557
+
558
+ # For dark posts, set feedDistribution to NONE
559
+ if ad_context.get("is_dsc"):
560
+ post_payload["distribution"]["feedDistribution"] = "NONE"
561
+
418
562
  # Create the post
419
563
  response = await self.api_client.post("/posts", data=post_payload)
420
564
 
@@ -534,7 +678,15 @@ class LinkedInClient(SocialMediaPlatform):
534
678
  # Handle additional LinkedIn-specific fields
535
679
  additional = getattr(request, "additional_data", {}) or {}
536
680
  if cta := additional.get("call_to_action"):
537
- patch_payload["patch"]["$set"]["contentCallToActionLabel"] = cta
681
+ cta_upper = cta.upper()
682
+ if cta_upper not in VALID_CTA_LABELS:
683
+ raise ValidationError(
684
+ f"Invalid call_to_action: '{cta}'. "
685
+ f"Valid values: {', '.join(sorted(VALID_CTA_LABELS))}",
686
+ platform=self.platform_name,
687
+ field="call_to_action",
688
+ )
689
+ patch_payload["patch"]["$set"]["contentCallToActionLabel"] = cta_upper
538
690
  if landing_page := additional.get("landing_page"):
539
691
  patch_payload["patch"]["$set"]["contentLandingPage"] = landing_page
540
692
  if lifecycle := additional.get("lifecycle_state"):
@@ -597,9 +749,13 @@ class LinkedInClient(SocialMediaPlatform):
597
749
  raise RuntimeError("API client not initialized")
598
750
 
599
751
  encoded_post_id = quote(post_id, safe="")
752
+ headers = {
753
+ **self._build_auth_headers(),
754
+ "X-RestLi-Method": "DELETE",
755
+ }
600
756
  await self.api_client._client.delete(
601
757
  f"{self.base_url}/posts/{encoded_post_id}",
602
- headers=self._build_auth_headers(),
758
+ headers=headers,
603
759
  )
604
760
  return True
605
761
 
@@ -693,6 +849,204 @@ class LinkedInClient(SocialMediaPlatform):
693
849
  platform=self.platform_name,
694
850
  ) from e
695
851
 
852
+ async def create_reshare(
853
+ self,
854
+ parent_post_urn: str,
855
+ commentary: str | None = None,
856
+ visibility: str = "PUBLIC",
857
+ ) -> Post:
858
+ """Create a reshare (repost) of an existing LinkedIn post.
859
+
860
+ Args:
861
+ parent_post_urn: URN of the post to reshare (urn:li:share:xxx or urn:li:ugcPost:xxx).
862
+ commentary: Optional commentary to add to the reshare.
863
+ visibility: Post visibility (PUBLIC, CONNECTIONS, LOGGED_IN).
864
+
865
+ Returns:
866
+ Created Post object.
867
+
868
+ Raises:
869
+ ValidationError: If parent_post_urn is invalid.
870
+
871
+ Example:
872
+ >>> reshare = await client.create_reshare(
873
+ ... "urn:li:share:6957408550713184256",
874
+ ... commentary="Great insights!"
875
+ ... )
876
+ """
877
+ if not self.api_client:
878
+ raise RuntimeError("Client must be used as async context manager")
879
+
880
+ try:
881
+ post_payload: dict[str, Any] = {
882
+ "author": self.author_urn,
883
+ "visibility": visibility,
884
+ "distribution": {
885
+ "feedDistribution": "MAIN_FEED",
886
+ "targetEntities": [],
887
+ "thirdPartyDistributionChannels": [],
888
+ },
889
+ "lifecycleState": "PUBLISHED",
890
+ "reshareContext": {
891
+ "parent": parent_post_urn,
892
+ },
893
+ }
894
+
895
+ if commentary:
896
+ post_payload["commentary"] = commentary
897
+
898
+ response = await self.api_client.post("/posts", data=post_payload)
899
+
900
+ post_id = response.data.get("id") or response.headers.get("x-restli-id")
901
+ if not post_id:
902
+ raise PlatformError(
903
+ "Failed to get post ID from response",
904
+ platform=self.platform_name,
905
+ )
906
+
907
+ return Post(
908
+ post_id=post_id,
909
+ platform=self.platform_name,
910
+ content=commentary or "",
911
+ status=PostStatus.PUBLISHED,
912
+ created_at=datetime.now(),
913
+ author_id=self.author_urn,
914
+ raw_data=response.data,
915
+ )
916
+
917
+ except httpx.HTTPError as e:
918
+ raise PlatformError(
919
+ f"Failed to create reshare: {e}",
920
+ platform=self.platform_name,
921
+ ) from e
922
+
923
+ async def batch_get_posts(self, post_ids: list[str]) -> list[Post]:
924
+ """Retrieve multiple posts by their URNs in a single request.
925
+
926
+ Uses the BATCH_GET method for efficient retrieval of multiple posts.
927
+
928
+ Args:
929
+ post_ids: List of post URNs (urn:li:share:xxx or urn:li:ugcPost:xxx).
930
+
931
+ Returns:
932
+ List of Post objects (in the same order as input if available).
933
+
934
+ Example:
935
+ >>> posts = await client.batch_get_posts([
936
+ ... "urn:li:share:123",
937
+ ... "urn:li:ugcPost:456"
938
+ ... ])
939
+ """
940
+ if not self.api_client:
941
+ raise RuntimeError("Client must be used as async context manager")
942
+
943
+ if not self.api_client._client:
944
+ raise RuntimeError("API client not initialized")
945
+
946
+ if not post_ids:
947
+ return []
948
+
949
+ try:
950
+ # URL encode each post ID
951
+ encoded_ids = [quote(pid, safe="") for pid in post_ids]
952
+ ids_param = f"List({','.join(encoded_ids)})"
953
+
954
+ headers = {
955
+ **self._build_auth_headers(),
956
+ "X-RestLi-Method": "BATCH_GET",
957
+ }
958
+
959
+ response = await self.api_client._client.get(
960
+ f"{self.base_url}/posts",
961
+ params={"ids": ids_param},
962
+ headers=headers,
963
+ )
964
+
965
+ data = response.json()
966
+ results = data.get("results", {})
967
+
968
+ # Parse posts, maintaining order where possible
969
+ posts = []
970
+ for post_id in post_ids:
971
+ if post_id in results:
972
+ posts.append(self._parse_post(results[post_id]))
973
+
974
+ return posts
975
+
976
+ except httpx.HTTPError as e:
977
+ raise PlatformError(
978
+ f"Failed to batch get posts: {e}",
979
+ platform=self.platform_name,
980
+ ) from e
981
+
982
+ async def list_dsc_posts(
983
+ self,
984
+ dsc_ad_account: str,
985
+ dsc_ad_types: list[str] | None = None,
986
+ limit: int = 10,
987
+ offset: int = 0,
988
+ ) -> list[Post]:
989
+ """List Direct Sponsored Content (DSC) posts by ad account.
990
+
991
+ Args:
992
+ dsc_ad_account: Sponsored account URN (urn:li:sponsoredAccount:xxx).
993
+ dsc_ad_types: Optional filter by DSC types (VIDEO, STANDARD, CAROUSEL,
994
+ JOB_POSTING, NATIVE_DOCUMENT, EVENT).
995
+ limit: Maximum number of posts to retrieve (max 100).
996
+ offset: Number of posts to skip for pagination.
997
+
998
+ Returns:
999
+ List of Post objects.
1000
+
1001
+ Example:
1002
+ >>> dsc_posts = await client.list_dsc_posts(
1003
+ ... "urn:li:sponsoredAccount:520866471",
1004
+ ... dsc_ad_types=["VIDEO", "STANDARD"]
1005
+ ... )
1006
+ """
1007
+ if not self.api_client:
1008
+ raise RuntimeError("Client must be used as async context manager")
1009
+
1010
+ if not self.api_client._client:
1011
+ raise RuntimeError("API client not initialized")
1012
+
1013
+ try:
1014
+ encoded_account = quote(dsc_ad_account, safe="")
1015
+
1016
+ params: dict[str, Any] = {
1017
+ "dscAdAccount": encoded_account,
1018
+ "q": "dscAdAccount",
1019
+ "count": min(limit, 100),
1020
+ "start": offset,
1021
+ }
1022
+
1023
+ if dsc_ad_types:
1024
+ params["dscAdTypes"] = f"List({','.join(dsc_ad_types)})"
1025
+
1026
+ headers = {
1027
+ **self._build_auth_headers(),
1028
+ "X-RestLi-Method": "FINDER",
1029
+ }
1030
+
1031
+ response = await self.api_client._client.get(
1032
+ f"{self.base_url}/posts",
1033
+ params=params,
1034
+ headers=headers,
1035
+ )
1036
+
1037
+ posts = []
1038
+ data = response.json()
1039
+ for post_data in data.get("elements", []):
1040
+ posts.append(self._parse_post(post_data))
1041
+
1042
+ return posts
1043
+
1044
+ except httpx.HTTPError as e:
1045
+ raise PlatformError(
1046
+ f"Failed to list DSC posts: {e}",
1047
+ platform=self.platform_name,
1048
+ ) from e
1049
+
696
1050
  # ==================== Comment Methods ====================
697
1051
 
698
1052
  async def get_comments(
@@ -1098,20 +1452,25 @@ class LinkedInClient(SocialMediaPlatform):
1098
1452
  *,
1099
1453
  title: str | None = None,
1100
1454
  ) -> MediaAsset:
1101
- """Upload a document/PDF to LinkedIn.
1455
+ """Upload a document to LinkedIn using the Documents API.
1102
1456
 
1103
- Convenience method for document uploads.
1457
+ Convenience method for document uploads. Supports PDF, PPT, PPTX, DOC, DOCX.
1104
1458
 
1105
1459
  Args:
1106
- file_path: Path to PDF file or URL.
1107
- title: Document title.
1460
+ file_path: Path to document file or URL.
1461
+ title: Document title (reserved for future use).
1108
1462
 
1109
1463
  Returns:
1110
- MediaAsset with asset ID.
1464
+ MediaAsset with document URN (urn:li:document:xxx).
1111
1465
 
1112
1466
  Example:
1113
1467
  >>> async with LinkedInClient(credentials) as client:
1114
1468
  ... asset = await client.upload_document("report.pdf")
1469
+ ... request = PostCreateRequest(
1470
+ ... content="Check out our report!",
1471
+ ... media_ids=[asset.asset_id]
1472
+ ... )
1473
+ ... post = await client.create_post(request)
1115
1474
  """
1116
1475
  if not self._media_manager:
1117
1476
  raise RuntimeError("Client must be used as async context manager")
@@ -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)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: marqetive-lib
3
- Version: 0.1.17
3
+ Version: 0.1.18
4
4
  Summary: Modern Python utilities for web APIs
5
5
  Keywords: api,utilities,web,http,marqetive
6
6
  Requires-Python: >=3.12
@@ -12,9 +12,9 @@ marqetive/platforms/instagram/exceptions.py,sha256=TcD_pX4eSx_k4yW8DgfA6SGPiAz3V
12
12
  marqetive/platforms/instagram/media.py,sha256=0ZbUbpwJ025_hccL9X8qced_-LJGoL_-NdS84Op97VE,23228
13
13
  marqetive/platforms/instagram/models.py,sha256=20v3m1037y3b_WlsKF8zAOgV23nFu63tfmmUN1CefOI,2769
14
14
  marqetive/platforms/linkedin/__init__.py,sha256=_FrdZpqcXjcUW6C-25zGV7poGih9yzs6y1AFnuizTUQ,1384
15
- marqetive/platforms/linkedin/client.py,sha256=8M34VJXFe_34QQZzhR7SVK8ee3iE6UOGsSVTv5Hf31U,58712
15
+ marqetive/platforms/linkedin/client.py,sha256=NHVjMfZb9Mp6zloCXORBEHOxOGPK5DHLctOzCiseQbs,73092
16
16
  marqetive/platforms/linkedin/exceptions.py,sha256=i5fARUkZik46bS3htZBwUInVzetsZx1APpKEXLrCKf0,9762
17
- marqetive/platforms/linkedin/media.py,sha256=iWXUfqDYGsrTqeM6CGZ7a8xjpbdJ5qESolQL8Fv2PIg,20341
17
+ marqetive/platforms/linkedin/media.py,sha256=-kpkL5brYhx8cZlFS5ueDgY4eSZWqHoB6xp8jZU2cyI,25931
18
18
  marqetive/platforms/linkedin/models.py,sha256=n7DqwVxYSbGYBmeEJ1woCZ6XhUIHcLx8Gpm8uCBACzI,12620
19
19
  marqetive/platforms/tiktok/__init__.py,sha256=BqjkXTZDyBlcY3lvREy13yP9h3RcDga8E6Rl6f5KPp8,238
20
20
  marqetive/platforms/tiktok/client.py,sha256=c-XYV3XsLU04jv61mBEYivP-oO-aZhuEq6g7J55OEY8,17387
@@ -33,6 +33,6 @@ marqetive/utils/helpers.py,sha256=Sh5HZD6AOJig_6T84n6JsKLosIkKIkpkiYTl69rnOOw,13
33
33
  marqetive/utils/media.py,sha256=reVousdueG-h5jeI6uLGqVCfjYxlsMiWhx6XZwg-iHY,14664
34
34
  marqetive/utils/oauth.py,sha256=x30XAW5VlND6TAPBsw9kZShko_Jsmn_NE-KOZjnBxGo,14359
35
35
  marqetive/utils/retry.py,sha256=UcgrmVBVG5zd30_11mZnRnTaSFrbUYXBO1DrXPR0f8E,7627
36
- marqetive_lib-0.1.17.dist-info/METADATA,sha256=mXa8m82FUduoiT9f6hCpeoNe-ygPA8TCqI7EOon__a8,7876
37
- marqetive_lib-0.1.17.dist-info/WHEEL,sha256=zp0Cn7JsFoX2ATtOhtaFYIiE2rmFAD4OcMhtUki8W3U,88
38
- marqetive_lib-0.1.17.dist-info/RECORD,,
36
+ marqetive_lib-0.1.18.dist-info/METADATA,sha256=NxSKJr6IOZzVngZLGwmTgDE97Wu8uyewyVK_fH-MZpI,7876
37
+ marqetive_lib-0.1.18.dist-info/WHEEL,sha256=zp0Cn7JsFoX2ATtOhtaFYIiE2rmFAD4OcMhtUki8W3U,88
38
+ marqetive_lib-0.1.18.dist-info/RECORD,,