marqetive-lib 0.1.8__py3-none-any.whl → 0.1.10__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.
@@ -216,12 +216,19 @@ class InstagramClient(SocialMediaPlatform):
216
216
  async def create_post(self, request: PostCreateRequest) -> Post:
217
217
  """Create and publish an Instagram post.
218
218
 
219
- Instagram requires a two-step process:
220
- 1. Create a media container
221
- 2. Publish the container
219
+ Automatically routes to the appropriate handler based on content type:
220
+ - IMAGE: Single image feed post
221
+ - CAROUSEL: Multiple images (2-10)
222
+ - REEL/VIDEO: Video content as Reel
223
+ - STORY: Story (image or video)
224
+
225
+ Content type can be specified via:
226
+ 1. InstagramPostRequest.media_type field
227
+ 2. PostCreateRequest.additional_data["media_type"]
228
+ 3. Auto-detected from media_urls count (1 = single, 2+ = carousel)
222
229
 
223
230
  Args:
224
- request: Post creation request.
231
+ request: Post creation request (PostCreateRequest or InstagramPostRequest).
225
232
 
226
233
  Returns:
227
234
  Created Post object.
@@ -229,6 +236,22 @@ class InstagramClient(SocialMediaPlatform):
229
236
  Raises:
230
237
  ValidationError: If request is invalid.
231
238
  MediaUploadError: If media upload fails.
239
+
240
+ Example:
241
+ >>> # Single image post
242
+ >>> request = PostCreateRequest(
243
+ ... content="Hello Instagram!",
244
+ ... media_urls=["https://example.com/image.jpg"]
245
+ ... )
246
+ >>> post = await client.create_post(request)
247
+
248
+ >>> # Reel via additional_data
249
+ >>> request = PostCreateRequest(
250
+ ... content="Check out this video!",
251
+ ... media_urls=["https://example.com/video.mp4"],
252
+ ... additional_data={"media_type": "reel"}
253
+ ... )
254
+ >>> post = await client.create_post(request)
232
255
  """
233
256
  if not self.api_client:
234
257
  raise RuntimeError("Client must be used as async context manager")
@@ -240,47 +263,185 @@ class InstagramClient(SocialMediaPlatform):
240
263
  field="media",
241
264
  )
242
265
 
243
- # Step 1: Create media container
244
- container_params: dict[str, Any] = {
245
- "access_token": self.credentials.access_token,
246
- }
266
+ # Determine content type from request
267
+ media_type = self._get_media_type(request)
247
268
 
248
- if request.media_urls:
249
- # Validate URL to prevent SSRF attacks
250
- validated_url = validate_media_url(
251
- request.media_urls[0], platform=self.platform_name
252
- )
253
- container_params["image_url"] = validated_url
269
+ # Route to appropriate handler based on content type
270
+ if media_type == MediaType.STORY:
271
+ return await self._create_story_post(request)
272
+ elif media_type in (MediaType.REEL, MediaType.VIDEO):
273
+ return await self._create_reel_post(request)
274
+ elif media_type == MediaType.CAROUSEL or len(request.media_urls) > 1:
275
+ return await self._create_carousel_post(request)
276
+ else:
277
+ return await self._create_single_image_post(request)
254
278
 
255
- if request.content:
256
- container_params["caption"] = request.content
279
+ def _get_media_type(self, request: PostCreateRequest) -> MediaType:
280
+ """Extract media type from request.
257
281
 
258
- try:
259
- # Create container
260
- container_response = await self.api_client.post(
261
- f"/{self.instagram_account_id}/media",
262
- data=container_params,
282
+ Checks in order:
283
+ 1. InstagramPostRequest.media_type (if using platform-specific model)
284
+ 2. PostCreateRequest.additional_data["media_type"]
285
+ 3. Auto-detect from media count
286
+
287
+ Args:
288
+ request: Post creation request.
289
+
290
+ Returns:
291
+ MediaType enum value.
292
+ """
293
+ # Check if it's an InstagramPostRequest with media_type
294
+ # Use getattr to avoid type checker issues with duck typing
295
+ media_type_attr = getattr(request, "media_type", None)
296
+ if media_type_attr is not None and isinstance(media_type_attr, MediaType):
297
+ return media_type_attr
298
+
299
+ # Check additional_data for media_type
300
+ if request.additional_data:
301
+ media_type_str = request.additional_data.get("media_type")
302
+ if media_type_str:
303
+ # Normalize and convert to enum
304
+ media_type_str = media_type_str.lower()
305
+ type_map = {
306
+ "image": MediaType.IMAGE,
307
+ "video": MediaType.VIDEO,
308
+ "reel": MediaType.REEL,
309
+ "reels": MediaType.REEL,
310
+ "story": MediaType.STORY,
311
+ "stories": MediaType.STORY,
312
+ "carousel": MediaType.CAROUSEL,
313
+ }
314
+ if media_type_str in type_map:
315
+ return type_map[media_type_str]
316
+
317
+ # Auto-detect: multiple URLs = carousel, single = image
318
+ if len(request.media_urls) > 1:
319
+ return MediaType.CAROUSEL
320
+
321
+ return MediaType.IMAGE
322
+
323
+ async def _create_single_image_post(self, request: PostCreateRequest) -> Post:
324
+ """Create a single image feed post.
325
+
326
+ Args:
327
+ request: Post creation request.
328
+
329
+ Returns:
330
+ Created Post object.
331
+ """
332
+ if not self._media_manager:
333
+ raise RuntimeError("Client must be used as async context manager")
334
+
335
+ # Extract options from additional_data
336
+ location_id = request.additional_data.get("location_id") or request.location
337
+ share_to_feed = request.additional_data.get("share_to_feed", True)
338
+
339
+ # Validate URL
340
+ validated_url = validate_media_url(
341
+ request.media_urls[0], platform=self.platform_name
342
+ )
343
+
344
+ # Create media item
345
+ media_item = MediaItem(url=validated_url, type="image")
346
+
347
+ # Get alt_texts if provided
348
+ alt_texts = request.additional_data.get("alt_texts", [])
349
+ if alt_texts:
350
+ media_item = MediaItem(
351
+ url=validated_url, type="image", alt_text=alt_texts[0]
263
352
  )
264
- container_id = container_response.data["id"]
265
353
 
266
- # Step 2: Publish container
267
- publish_response = await self.api_client.post(
268
- f"/{self.instagram_account_id}/media_publish",
269
- data={
270
- "creation_id": container_id,
271
- "access_token": self.credentials.access_token,
272
- },
354
+ # Create container and publish
355
+ container_ids = await self._media_manager.create_feed_containers(
356
+ [media_item],
357
+ caption=request.content,
358
+ location_id=location_id,
359
+ share_to_feed=share_to_feed,
360
+ )
361
+
362
+ result = await self._media_manager.publish_container(container_ids[0])
363
+ return await self.get_post(result.media_id)
364
+
365
+ async def _create_carousel_post(self, request: PostCreateRequest) -> Post:
366
+ """Create a carousel post (2-10 images).
367
+
368
+ Args:
369
+ request: Post creation request.
370
+
371
+ Returns:
372
+ Created Post object.
373
+ """
374
+ # Extract options from additional_data
375
+ alt_texts = request.additional_data.get("alt_texts")
376
+ location_id = request.additional_data.get("location_id") or request.location
377
+
378
+ return await self.create_carousel(
379
+ media_urls=request.media_urls,
380
+ caption=request.content,
381
+ alt_texts=alt_texts,
382
+ location_id=location_id,
383
+ )
384
+
385
+ async def _create_reel_post(self, request: PostCreateRequest) -> Post:
386
+ """Create a Reel (video post).
387
+
388
+ Args:
389
+ request: Post creation request.
390
+
391
+ Returns:
392
+ Created Post object.
393
+ """
394
+ if not request.media_urls:
395
+ raise ValidationError(
396
+ "Reel requires a video URL",
397
+ platform=self.platform_name,
398
+ field="media_urls",
273
399
  )
274
- post_id = publish_response.data["id"]
275
400
 
276
- # Fetch full post details
277
- return await self.get_post(post_id)
401
+ # Extract reel-specific options from additional_data
402
+ cover_url = request.additional_data.get("cover_url")
403
+ share_to_feed = request.additional_data.get("share_to_feed", True)
278
404
 
279
- except httpx.HTTPError as e:
280
- raise MediaUploadError(
281
- f"Failed to create Instagram post: {e}",
405
+ return await self.create_reel(
406
+ video_url=request.media_urls[0],
407
+ caption=request.content,
408
+ cover_url=cover_url,
409
+ share_to_feed=share_to_feed,
410
+ )
411
+
412
+ async def _create_story_post(self, request: PostCreateRequest) -> Post:
413
+ """Create an Instagram Story.
414
+
415
+ Args:
416
+ request: Post creation request.
417
+
418
+ Returns:
419
+ Created Post object.
420
+ """
421
+ if not request.media_urls:
422
+ raise ValidationError(
423
+ "Story requires a media URL",
282
424
  platform=self.platform_name,
283
- ) from e
425
+ field="media_urls",
426
+ )
427
+
428
+ # Determine if it's image or video from additional_data or file extension
429
+ story_media_type: Literal["image", "video"] = "image"
430
+
431
+ # Check additional_data for explicit type
432
+ if request.additional_data.get("story_media_type"):
433
+ story_media_type = request.additional_data["story_media_type"]
434
+ else:
435
+ # Auto-detect from URL extension
436
+ url_lower = request.media_urls[0].lower()
437
+ video_extensions = (".mp4", ".mov", ".avi", ".webm")
438
+ if any(url_lower.endswith(ext) for ext in video_extensions):
439
+ story_media_type = "video"
440
+
441
+ return await self.create_story(
442
+ media_url=request.media_urls[0],
443
+ media_type=story_media_type,
444
+ )
284
445
 
285
446
  async def get_post(self, post_id: str) -> Post:
286
447
  """Retrieve an Instagram post by ID.
@@ -231,15 +231,26 @@ class TikTokClient(SocialMediaPlatform):
231
231
  wait_for_publish=True,
232
232
  )
233
233
 
234
- if not upload_result.video_id:
235
- raise PlatformError(
236
- "Video upload succeeded but no video ID returned. "
237
- "Video may still be processing.",
238
- platform=self.platform_name,
239
- )
234
+ # 4. Return post - either fetch full details or create minimal Post
235
+ if upload_result.video_id:
236
+ # Fetch the created post to return full Post object
237
+ return await self.get_post(upload_result.video_id)
240
238
 
241
- # 4. Fetch the created post to return full Post object
242
- return await self.get_post(upload_result.video_id)
239
+ # For private/SELF_ONLY posts, TikTok may not return video_id
240
+ # Return a minimal Post object with publish_id
241
+ return Post(
242
+ post_id=upload_result.publish_id,
243
+ platform=self.platform_name,
244
+ content=request.content,
245
+ status=PostStatus.PUBLISHED,
246
+ created_at=datetime.now(),
247
+ raw_data={
248
+ "publish_id": upload_result.publish_id,
249
+ "upload_status": upload_result.status,
250
+ "privacy_level": privacy_level.value,
251
+ "note": "Video published but video_id not returned (common for private posts)",
252
+ },
253
+ )
243
254
 
244
255
  async def get_post(self, post_id: str) -> Post:
245
256
  """Retrieve a TikTok video by its ID.
@@ -531,12 +531,22 @@ class TikTokMediaManager:
531
531
  result_data = data.get("data", {})
532
532
  status = result_data.get("status", PublishStatus.FAILED)
533
533
 
534
+ logger.debug(f"TikTok publish status response: {data}")
535
+
534
536
  video_id = None
535
537
  if status == PublishStatus.PUBLISH_COMPLETE:
536
- # Video IDs are in publicaly_available_post_id array
538
+ # Video IDs are in publicaly_available_post_id array (note TikTok's typo)
539
+ # For private/SELF_ONLY posts, this may be empty
537
540
  video_ids = result_data.get("publicaly_available_post_id", [])
538
541
  if video_ids:
539
542
  video_id = str(video_ids[0])
543
+ else:
544
+ # Try alternative field names that TikTok might use
545
+ video_id = result_data.get("video_id") or result_data.get("item_id")
546
+ logger.warning(
547
+ f"No video ID in publicaly_available_post_id, "
548
+ f"tried alternatives: video_id={video_id}"
549
+ )
540
550
 
541
551
  return MediaUploadResult(
542
552
  publish_id=publish_id,
@@ -713,6 +723,11 @@ class TikTokMediaManager:
713
723
  def _normalize_chunk_size(self, chunk_size: int, file_size: int) -> int:
714
724
  """Normalize chunk size to TikTok's requirements.
715
725
 
726
+ TikTok chunk requirements:
727
+ - Minimum chunk size: 5MB (except for files smaller than 5MB)
728
+ - Maximum chunk size: 64MB (final chunk can be up to 128MB)
729
+ - All non-final chunks must be at least MIN_CHUNK_SIZE
730
+
716
731
  Args:
717
732
  chunk_size: Requested chunk size.
718
733
  file_size: Total file size.
@@ -720,13 +735,27 @@ class TikTokMediaManager:
720
735
  Returns:
721
736
  Normalized chunk size within TikTok limits.
722
737
  """
723
- # Files smaller than MIN_CHUNK_SIZE must be uploaded as single chunk
724
- if file_size < MIN_CHUNK_SIZE:
738
+ # Files smaller than MAX_CHUNK_SIZE (64MB) should be uploaded as single chunk
739
+ # This avoids issues with the final chunk being smaller than MIN_CHUNK_SIZE
740
+ if file_size <= MAX_CHUNK_SIZE:
725
741
  return file_size
726
742
 
727
- # Ensure within limits
743
+ # For larger files, ensure chunk size is within limits
728
744
  chunk_size = max(MIN_CHUNK_SIZE, min(chunk_size, MAX_CHUNK_SIZE))
729
745
 
746
+ # Ensure the last chunk won't be smaller than MIN_CHUNK_SIZE
747
+ # If it would be, increase chunk size to make fewer, larger chunks
748
+ total_chunks = math.ceil(file_size / chunk_size)
749
+ last_chunk_size = file_size - (chunk_size * (total_chunks - 1))
750
+
751
+ if last_chunk_size < MIN_CHUNK_SIZE and total_chunks > 1:
752
+ # Recalculate to have fewer chunks with larger size
753
+ # Use ceiling division to ensure last chunk is large enough
754
+ total_chunks = math.ceil(file_size / MAX_CHUNK_SIZE)
755
+ chunk_size = math.ceil(file_size / total_chunks)
756
+ # Ensure still within limits
757
+ chunk_size = max(MIN_CHUNK_SIZE, min(chunk_size, MAX_CHUNK_SIZE))
758
+
730
759
  return chunk_size
731
760
 
732
761
  def _validate_media(self, mime_type: str, file_size: int) -> None:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: marqetive-lib
3
- Version: 0.1.8
3
+ Version: 0.1.10
4
4
  Summary: Modern Python utilities for web APIs
5
5
  Keywords: api,utilities,web,http,marqetive
6
6
  Requires-Python: >=3.12
@@ -7,7 +7,7 @@ marqetive/core/models.py,sha256=L2gA4FhW0feAXQFsz2ce1ttd0vScMRhatoTclhDGCU0,1472
7
7
  marqetive/factory.py,sha256=irZ5oN8a__kXZH70UN2uI7TzqTXu66d4QZ1FoxSoiK8,14092
8
8
  marqetive/platforms/__init__.py,sha256=RBxlQSGyELsulSnwf5uaE1ohxFc7jC61OO9CrKaZp48,1312
9
9
  marqetive/platforms/instagram/__init__.py,sha256=c1Gs0ozG6D7Z-Uz_UQ7S3joL0qUTT9eUZPWcePyESk8,229
10
- marqetive/platforms/instagram/client.py,sha256=vOx5HpgrxanBIFFC9VgmCNguH-njRGChnyp6Rr1r1Xc,26191
10
+ marqetive/platforms/instagram/client.py,sha256=_ccxn3RUZDooR2w-vQocsxhctRB_MAhn3ZKQroJMv8c,32217
11
11
  marqetive/platforms/instagram/exceptions.py,sha256=TcD_pX4eSx_k4yW8DgfA6SGPiAz3VW7cMqM8DmiXIhg,8978
12
12
  marqetive/platforms/instagram/media.py,sha256=0ZbUbpwJ025_hccL9X8qced_-LJGoL_-NdS84Op97VE,23228
13
13
  marqetive/platforms/instagram/models.py,sha256=20v3m1037y3b_WlsKF8zAOgV23nFu63tfmmUN1CefOI,2769
@@ -17,9 +17,9 @@ marqetive/platforms/linkedin/exceptions.py,sha256=i5fARUkZik46bS3htZBwUInVzetsZx
17
17
  marqetive/platforms/linkedin/media.py,sha256=iWXUfqDYGsrTqeM6CGZ7a8xjpbdJ5qESolQL8Fv2PIg,20341
18
18
  marqetive/platforms/linkedin/models.py,sha256=n7DqwVxYSbGYBmeEJ1woCZ6XhUIHcLx8Gpm8uCBACzI,12620
19
19
  marqetive/platforms/tiktok/__init__.py,sha256=BqjkXTZDyBlcY3lvREy13yP9h3RcDga8E6Rl6f5KPp8,238
20
- marqetive/platforms/tiktok/client.py,sha256=wCCCFQ4mGiZrrGYjRUCUngz6_eqf4G6BUxYxw8szpig,17178
20
+ marqetive/platforms/tiktok/client.py,sha256=ErhJOskyWVJP1nfLeJl09fGEyVh3QEhjYCgRlVLV-JY,17704
21
21
  marqetive/platforms/tiktok/exceptions.py,sha256=vxwyAKujMGZJh0LetG1QsLF95QfUs_kR6ujsWSHGqL0,10124
22
- marqetive/platforms/tiktok/media.py,sha256=bPQmyVL8egb4teXQDzxQvWLwg2EnBh4Ik6lz20ReFvg,27008
22
+ marqetive/platforms/tiktok/media.py,sha256=aa47EDRA7woKGqKZkl_5XWu7kcRp2nT93Ol2skEQJpY,28592
23
23
  marqetive/platforms/tiktok/models.py,sha256=WWdjuFqhTIR8SnHkz-8UaNc5Mm2PrGomwQ3W7pJcQFg,2962
24
24
  marqetive/platforms/twitter/__init__.py,sha256=dvcgVT-v-JOtjSz-OUvxGrn_43OI6w_ep42Wx_nHTSM,217
25
25
  marqetive/platforms/twitter/client.py,sha256=08jV2hQVmGOpnG3C05u7bCqL7KapWn7bSsG0wbN_t5M,23270
@@ -34,6 +34,6 @@ marqetive/utils/media.py,sha256=O1rISYdaP3CuuPxso7kqvxWXNfe2jjioNkaBc4cpwkY,1466
34
34
  marqetive/utils/oauth.py,sha256=1SkYCE6dcyPvcDqbjRFSSBcKTwLJy8u3jAANPdftVmo,13108
35
35
  marqetive/utils/retry.py,sha256=lAniJLMNWp9XsHrvU0XBNifpNEjfde4MGfd5hlFTPfA,7636
36
36
  marqetive/utils/token_validator.py,sha256=dNvDeHs2Du5UyMMH2ZOW6ydR7OwOEKA4c9e-rG0f9-0,6698
37
- marqetive_lib-0.1.8.dist-info/METADATA,sha256=q99TsfInwfKVr_zoFnkvM3Wvb5cGZ7Nj6qQikle5EG4,7875
38
- marqetive_lib-0.1.8.dist-info/WHEEL,sha256=zp0Cn7JsFoX2ATtOhtaFYIiE2rmFAD4OcMhtUki8W3U,88
39
- marqetive_lib-0.1.8.dist-info/RECORD,,
37
+ marqetive_lib-0.1.10.dist-info/METADATA,sha256=oFcLMxRxsYYnE1CQDOh2LZ0OCY3GY_n5FNgDFJ9h9_8,7876
38
+ marqetive_lib-0.1.10.dist-info/WHEEL,sha256=zp0Cn7JsFoX2ATtOhtaFYIiE2rmFAD4OcMhtUki8W3U,88
39
+ marqetive_lib-0.1.10.dist-info/RECORD,,