marqetive-lib 0.1.6__py3-none-any.whl → 0.1.8__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.
@@ -10,24 +10,35 @@ Reference: https://developers.tiktok.com/doc/content-posting-api-reference-direc
10
10
  """
11
11
 
12
12
  import asyncio
13
+ import inspect
14
+ import json
13
15
  import logging
14
16
  import math
15
17
  import os
16
- from collections.abc import Callable
18
+ from collections.abc import Awaitable, Callable
17
19
  from dataclasses import dataclass
18
20
  from enum import Enum
19
21
  from typing import Any, Literal
20
22
 
23
+ import aiofiles
21
24
  import httpx
22
25
 
23
26
  from marqetive.core.exceptions import (
24
27
  InvalidFileTypeError,
25
28
  MediaUploadError,
29
+ PlatformError,
26
30
  )
31
+ from marqetive.core.models import ProgressEvent, ProgressStatus
27
32
  from marqetive.platforms.tiktok.exceptions import TikTokErrorCode, map_tiktok_error
33
+ from marqetive.platforms.tiktok.models import PrivacyLevel
28
34
  from marqetive.utils.file_handlers import download_file
29
35
  from marqetive.utils.media import detect_mime_type, format_file_size
30
36
 
37
+ # Type aliases for progress callbacks
38
+ type SyncProgressCallback = Callable[[ProgressEvent], None]
39
+ type AsyncProgressCallback = Callable[[ProgressEvent], Awaitable[None]]
40
+ type ProgressCallback = SyncProgressCallback | AsyncProgressCallback
41
+
31
42
  logger = logging.getLogger(__name__)
32
43
 
33
44
  # TikTok API Base URLs
@@ -62,17 +73,21 @@ class PublishStatus(str, Enum):
62
73
  FAILED = "FAILED"
63
74
 
64
75
 
65
- class PrivacyLevel(str, Enum):
66
- """Privacy level options for TikTok posts."""
67
-
68
- PUBLIC_TO_EVERYONE = "PUBLIC_TO_EVERYONE"
69
- MUTUAL_FOLLOW_FRIENDS = "MUTUAL_FOLLOW_FRIENDS"
70
- SELF_ONLY = "SELF_ONLY" # Private - only for unaudited apps or private posts
71
-
72
-
73
76
  @dataclass
74
77
  class UploadProgress:
75
- """Progress information for a media upload."""
78
+ """Progress information for a media upload.
79
+
80
+ .. deprecated:: 0.2.0
81
+ Use :class:`marqetive.core.models.ProgressEvent` instead.
82
+ This class will be removed in a future version.
83
+
84
+ Attributes:
85
+ publish_id: TikTok publish ID (if available).
86
+ file_path: Path to file being uploaded.
87
+ bytes_uploaded: Number of bytes uploaded so far.
88
+ total_bytes: Total file size in bytes.
89
+ status: Current upload status.
90
+ """
76
91
 
77
92
  publish_id: str | None
78
93
  file_path: str
@@ -141,7 +156,7 @@ class TikTokMediaManager:
141
156
  access_token: str,
142
157
  open_id: str,
143
158
  *,
144
- progress_callback: Callable[[UploadProgress], None] | None = None,
159
+ progress_callback: ProgressCallback | None = None,
145
160
  timeout: float = DEFAULT_REQUEST_TIMEOUT,
146
161
  ) -> None:
147
162
  """Initialize the TikTok media manager.
@@ -149,7 +164,8 @@ class TikTokMediaManager:
149
164
  Args:
150
165
  access_token: OAuth access token with video.publish scope.
151
166
  open_id: User's open_id from OAuth flow.
152
- progress_callback: Optional callback for upload progress updates.
167
+ progress_callback: Optional callback for progress updates.
168
+ Accepts ProgressEvent and can be sync or async.
153
169
  timeout: Request timeout in seconds.
154
170
  """
155
171
  self.access_token = access_token
@@ -176,6 +192,65 @@ class TikTokMediaManager:
176
192
  await self._client.aclose()
177
193
  await self._upload_client.aclose()
178
194
 
195
+ def _parse_json_response(self, response: httpx.Response) -> dict[str, Any]:
196
+ """Parse JSON from HTTP response with proper error handling.
197
+
198
+ Args:
199
+ response: HTTP response object.
200
+
201
+ Returns:
202
+ Parsed JSON data as dictionary.
203
+
204
+ Raises:
205
+ PlatformError: If response is not valid JSON.
206
+ """
207
+ try:
208
+ return response.json()
209
+ except json.JSONDecodeError as e:
210
+ raise PlatformError(
211
+ f"Invalid JSON response from TikTok API: {e}",
212
+ platform="tiktok",
213
+ status_code=response.status_code,
214
+ ) from e
215
+
216
+ async def _emit_progress(
217
+ self,
218
+ status: ProgressStatus,
219
+ progress: int,
220
+ total: int,
221
+ message: str | None = None,
222
+ *,
223
+ entity_id: str | None = None,
224
+ file_path: str | None = None,
225
+ bytes_uploaded: int | None = None,
226
+ total_bytes: int | None = None,
227
+ ) -> None:
228
+ """Emit a progress update if a callback is registered.
229
+
230
+ Supports both sync and async callbacks.
231
+ """
232
+ if self.progress_callback is None:
233
+ return
234
+
235
+ event = ProgressEvent(
236
+ operation="upload_media",
237
+ platform="tiktok",
238
+ status=status,
239
+ progress=progress,
240
+ total=total,
241
+ message=message,
242
+ entity_id=entity_id,
243
+ file_path=file_path,
244
+ bytes_uploaded=bytes_uploaded,
245
+ total_bytes=total_bytes,
246
+ )
247
+
248
+ result = self.progress_callback(event)
249
+
250
+ # If callback returned a coroutine, await it
251
+ if inspect.iscoroutine(result):
252
+ await result
253
+
179
254
  async def query_creator_info(self) -> CreatorInfo:
180
255
  """Query creator info before posting.
181
256
 
@@ -191,7 +266,7 @@ class TikTokMediaManager:
191
266
  url = f"{TIKTOK_API_BASE}/post/publish/creator_info/query/"
192
267
 
193
268
  response = await self._client.post(url)
194
- data = response.json()
269
+ data = self._parse_json_response(response)
195
270
 
196
271
  self._check_response_error(response.status_code, data)
197
272
 
@@ -214,7 +289,7 @@ class TikTokMediaManager:
214
289
  file_path: str,
215
290
  *,
216
291
  title: str = "",
217
- privacy_level: PrivacyLevel = PrivacyLevel.SELF_ONLY,
292
+ privacy_level: PrivacyLevel = PrivacyLevel.PRIVATE,
218
293
  disable_duet: bool = False,
219
294
  disable_comment: bool = False,
220
295
  disable_stitch: bool = False,
@@ -263,7 +338,7 @@ class TikTokMediaManager:
263
338
  }
264
339
 
265
340
  response = await self._client.post(url, json=payload)
266
- data = response.json()
341
+ data = self._parse_json_response(response)
267
342
 
268
343
  self._check_response_error(response.status_code, data)
269
344
 
@@ -285,7 +360,7 @@ class TikTokMediaManager:
285
360
  video_url: str,
286
361
  *,
287
362
  title: str = "",
288
- privacy_level: PrivacyLevel = PrivacyLevel.SELF_ONLY,
363
+ privacy_level: PrivacyLevel = PrivacyLevel.PRIVATE,
289
364
  disable_duet: bool = False,
290
365
  disable_comment: bool = False,
291
366
  disable_stitch: bool = False,
@@ -328,7 +403,7 @@ class TikTokMediaManager:
328
403
  }
329
404
 
330
405
  response = await self._client.post(url, json=payload)
331
- data = response.json()
406
+ data = self._parse_json_response(response)
332
407
 
333
408
  self._check_response_error(response.status_code, data)
334
409
 
@@ -368,23 +443,23 @@ class TikTokMediaManager:
368
443
  chunk_size = self._normalize_chunk_size(chunk_size, file_size)
369
444
  mime_type = detect_mime_type(file_path)
370
445
 
371
- if self.progress_callback:
372
- self.progress_callback(
373
- UploadProgress(
374
- publish_id=publish_id,
375
- file_path=file_path,
376
- bytes_uploaded=0,
377
- total_bytes=file_size,
378
- status="uploading",
379
- )
380
- )
446
+ await self._emit_progress(
447
+ status=ProgressStatus.UPLOADING,
448
+ progress=0,
449
+ total=100,
450
+ message="Starting video upload",
451
+ entity_id=publish_id,
452
+ file_path=file_path,
453
+ bytes_uploaded=0,
454
+ total_bytes=file_size,
455
+ )
381
456
 
382
457
  bytes_uploaded = 0
383
458
 
384
- with open(file_path, "rb") as f:
459
+ async with aiofiles.open(file_path, "rb") as f:
385
460
  chunk_index = 0
386
461
  while True:
387
- chunk_data = f.read(chunk_size)
462
+ chunk_data = await f.read(chunk_size)
388
463
  if not chunk_data:
389
464
  break
390
465
 
@@ -419,16 +494,16 @@ class TikTokMediaManager:
419
494
  bytes_uploaded += len(chunk_data)
420
495
  chunk_index += 1
421
496
 
422
- if self.progress_callback:
423
- self.progress_callback(
424
- UploadProgress(
425
- publish_id=publish_id,
426
- file_path=file_path,
427
- bytes_uploaded=bytes_uploaded,
428
- total_bytes=file_size,
429
- status="uploading",
430
- )
431
- )
497
+ await self._emit_progress(
498
+ status=ProgressStatus.UPLOADING,
499
+ progress=int((bytes_uploaded / file_size) * 100),
500
+ total=100,
501
+ message=f"Uploading chunk {chunk_index}",
502
+ entity_id=publish_id,
503
+ file_path=file_path,
504
+ bytes_uploaded=bytes_uploaded,
505
+ total_bytes=file_size,
506
+ )
432
507
 
433
508
  logger.info(
434
509
  f"TikTok video upload complete: {bytes_uploaded} bytes in {chunk_index} chunks"
@@ -449,7 +524,7 @@ class TikTokMediaManager:
449
524
  url = f"{TIKTOK_API_BASE}/post/publish/status/fetch/"
450
525
 
451
526
  response = await self._client.post(url, json={"publish_id": publish_id})
452
- data = response.json()
527
+ data = self._parse_json_response(response)
453
528
 
454
529
  self._check_response_error(response.status_code, data)
455
530
 
@@ -490,16 +565,17 @@ class TikTokMediaManager:
490
565
  Raises:
491
566
  MediaUploadError: If publishing fails or times out.
492
567
  """
493
- if self.progress_callback and file_path:
568
+ if file_path:
494
569
  file_size = os.path.getsize(file_path) if os.path.exists(file_path) else 0
495
- self.progress_callback(
496
- UploadProgress(
497
- publish_id=publish_id,
498
- file_path=file_path,
499
- bytes_uploaded=file_size,
500
- total_bytes=file_size,
501
- status="processing",
502
- )
570
+ await self._emit_progress(
571
+ status=ProgressStatus.PROCESSING,
572
+ progress=0,
573
+ total=100,
574
+ message="Processing video on TikTok servers",
575
+ entity_id=publish_id,
576
+ file_path=file_path,
577
+ bytes_uploaded=file_size,
578
+ total_bytes=file_size,
503
579
  )
504
580
 
505
581
  elapsed = 0.0
@@ -508,31 +584,33 @@ class TikTokMediaManager:
508
584
 
509
585
  if result.status == PublishStatus.PUBLISH_COMPLETE:
510
586
  logger.info(f"TikTok video published: video_id={result.video_id}")
511
- if self.progress_callback and file_path:
587
+ if file_path:
512
588
  file_size = (
513
589
  os.path.getsize(file_path) if os.path.exists(file_path) else 0
514
590
  )
515
- self.progress_callback(
516
- UploadProgress(
517
- publish_id=publish_id,
518
- file_path=file_path,
519
- bytes_uploaded=file_size,
520
- total_bytes=file_size,
521
- status="completed",
522
- )
591
+ await self._emit_progress(
592
+ status=ProgressStatus.COMPLETED,
593
+ progress=100,
594
+ total=100,
595
+ message="Video published successfully",
596
+ entity_id=publish_id,
597
+ file_path=file_path,
598
+ bytes_uploaded=file_size,
599
+ total_bytes=file_size,
523
600
  )
524
601
  return result
525
602
 
526
603
  if result.status == PublishStatus.FAILED:
527
- if self.progress_callback and file_path:
528
- self.progress_callback(
529
- UploadProgress(
530
- publish_id=publish_id,
531
- file_path=file_path,
532
- bytes_uploaded=0,
533
- total_bytes=0,
534
- status="failed",
535
- )
604
+ if file_path:
605
+ await self._emit_progress(
606
+ status=ProgressStatus.FAILED,
607
+ progress=0,
608
+ total=100,
609
+ message="Video publishing failed",
610
+ entity_id=publish_id,
611
+ file_path=file_path,
612
+ bytes_uploaded=0,
613
+ total_bytes=0,
536
614
  )
537
615
  raise MediaUploadError(
538
616
  f"TikTok video publishing failed: publish_id={publish_id}",
@@ -553,7 +631,7 @@ class TikTokMediaManager:
553
631
  file_path: str,
554
632
  *,
555
633
  title: str = "",
556
- privacy_level: PrivacyLevel = PrivacyLevel.SELF_ONLY,
634
+ privacy_level: PrivacyLevel = PrivacyLevel.PRIVATE,
557
635
  chunk_size: int = DEFAULT_CHUNK_SIZE,
558
636
  wait_for_publish: bool = True,
559
637
  ) -> MediaUploadResult:
@@ -581,43 +659,56 @@ class TikTokMediaManager:
581
659
  InvalidFileTypeError: If the file is not a supported video format.
582
660
  MediaUploadError: If upload or processing fails.
583
661
  """
584
- # Handle URL downloads
585
- if file_path.startswith(("http://", "https://")):
586
- logger.info(f"Downloading media from URL: {file_path}")
587
- file_path = await download_file(file_path)
588
-
589
- if not os.path.exists(file_path):
590
- raise FileNotFoundError(f"Media file not found: {file_path}")
591
-
592
- # Validate file
593
- mime_type = detect_mime_type(file_path)
594
- file_size = os.path.getsize(file_path)
595
- self._validate_media(mime_type, file_size)
596
-
597
- # Initialize upload
598
- init_result = await self.init_video_upload(
599
- file_path,
600
- title=title,
601
- privacy_level=privacy_level,
602
- chunk_size=chunk_size,
603
- )
662
+ # Track if we downloaded a temp file that needs cleanup
663
+ temp_file_path: str | None = None
664
+
665
+ try:
666
+ # Handle URL downloads
667
+ if file_path.startswith(("http://", "https://")):
668
+ logger.info(f"Downloading media from URL: {file_path}")
669
+ file_path = await download_file(file_path)
670
+ temp_file_path = file_path # Mark for cleanup
671
+
672
+ if not os.path.exists(file_path):
673
+ raise FileNotFoundError(f"Media file not found: {file_path}")
674
+
675
+ # Validate file
676
+ mime_type = detect_mime_type(file_path)
677
+ file_size = os.path.getsize(file_path)
678
+ self._validate_media(mime_type, file_size)
679
+
680
+ # Initialize upload
681
+ init_result = await self.init_video_upload(
682
+ file_path,
683
+ title=title,
684
+ privacy_level=privacy_level,
685
+ chunk_size=chunk_size,
686
+ )
604
687
 
605
- # Upload chunks
606
- await self.upload_video_chunks(
607
- init_result.upload_url,
608
- file_path,
609
- init_result.publish_id,
610
- chunk_size=chunk_size,
611
- )
688
+ # Upload chunks
689
+ await self.upload_video_chunks(
690
+ init_result.upload_url,
691
+ file_path,
692
+ init_result.publish_id,
693
+ chunk_size=chunk_size,
694
+ )
612
695
 
613
- # Wait for publish
614
- if wait_for_publish:
615
- return await self.wait_for_publish(init_result.publish_id, file_path)
696
+ # Wait for publish
697
+ if wait_for_publish:
698
+ return await self.wait_for_publish(init_result.publish_id, file_path)
616
699
 
617
- return MediaUploadResult(
618
- publish_id=init_result.publish_id,
619
- status=PublishStatus.PROCESSING_UPLOAD,
620
- )
700
+ return MediaUploadResult(
701
+ publish_id=init_result.publish_id,
702
+ status=PublishStatus.PROCESSING_UPLOAD,
703
+ )
704
+ finally:
705
+ # Clean up downloaded temp file
706
+ if temp_file_path and os.path.exists(temp_file_path):
707
+ try:
708
+ os.remove(temp_file_path)
709
+ logger.debug(f"Cleaned up temp file: {temp_file_path}")
710
+ except OSError as e:
711
+ logger.warning(f"Failed to clean up temp file {temp_file_path}: {e}")
621
712
 
622
713
  def _normalize_chunk_size(self, chunk_size: int, file_size: int) -> int:
623
714
  """Normalize chunk size to TikTok's requirements.
@@ -0,0 +1,79 @@
1
+ """TikTok-specific models for post creation.
2
+
3
+ This module defines TikTok-specific data models for creating video posts,
4
+ including privacy settings and content toggles.
5
+ """
6
+
7
+ from enum import StrEnum
8
+
9
+ from pydantic import BaseModel, Field
10
+
11
+
12
+ class PrivacyLevel(StrEnum):
13
+ """Privacy level options for TikTok posts.
14
+
15
+ Attributes:
16
+ PUBLIC: Visible to everyone
17
+ FRIENDS: Visible to mutual followers/friends only
18
+ PRIVATE: Visible only to the author (for unaudited apps)
19
+ """
20
+
21
+ PUBLIC = "PUBLIC_TO_EVERYONE"
22
+ FRIENDS = "MUTUAL_FOLLOW_FRIENDS"
23
+ PRIVATE = "SELF_ONLY"
24
+
25
+
26
+ class TikTokPostRequest(BaseModel):
27
+ """TikTok-specific post creation request with full API options.
28
+
29
+ This model provides a type-safe way to configure TikTok-specific post settings.
30
+ It can be converted to the universal PostCreateRequest for use with TikTokClient.
31
+
32
+ TikTok only supports video posts. Videos must be between 3 seconds
33
+ and 10 minutes, and under 4GB in size.
34
+
35
+ Attributes:
36
+ title: Video title/caption (max 2200 characters)
37
+ video_url: URL to video file (required)
38
+ video_id: Pre-uploaded video ID
39
+ privacy_level: Privacy setting (PUBLIC, FRIENDS, PRIVATE)
40
+ disable_comment: Disable comments on the video
41
+ disable_duet: Disable duet feature
42
+ disable_stitch: Disable stitch feature
43
+ video_cover_timestamp_ms: Timestamp in ms for auto-generated cover
44
+ brand_content_toggle: Mark as branded/sponsored content
45
+ brand_organic_toggle: Mark as organic branded content
46
+ schedule_time: Unix timestamp to schedule post (10 mins to 10 days ahead)
47
+
48
+ Example:
49
+ >>> from marqetive.core.models import PostCreateRequest
50
+ >>>
51
+ >>> # Create TikTok-specific request
52
+ >>> tiktok_request = TikTokPostRequest(
53
+ ... title="Check out this dance!",
54
+ ... video_url="https://example.com/dance.mp4",
55
+ ... privacy_level=PrivacyLevel.PUBLIC,
56
+ ... disable_duet=True,
57
+ ... )
58
+ >>>
59
+ >>> # Convert to universal PostCreateRequest for client
60
+ >>> request = PostCreateRequest(
61
+ ... content=tiktok_request.title,
62
+ ... media_urls=[tiktok_request.video_url] if tiktok_request.video_url else [],
63
+ ... additional_data=tiktok_request.model_dump(exclude_none=True),
64
+ ... )
65
+ >>> async with TikTokClient(credentials) as client:
66
+ ... post = await client.create_post(request)
67
+ """
68
+
69
+ title: str | None = None
70
+ video_url: str | None = None
71
+ video_id: str | None = None
72
+ privacy_level: PrivacyLevel = PrivacyLevel.PRIVATE
73
+ disable_comment: bool = False
74
+ disable_duet: bool = False
75
+ disable_stitch: bool = False
76
+ video_cover_timestamp_ms: int | None = None
77
+ brand_content_toggle: bool = False
78
+ brand_organic_toggle: bool = False
79
+ schedule_time: int | None = Field(default=None, description="Unix timestamp")
@@ -1,5 +1,6 @@
1
1
  """Twitter/X platform integration."""
2
2
 
3
3
  from marqetive.platforms.twitter.client import TwitterClient
4
+ from marqetive.platforms.twitter.models import TwitterPostRequest
4
5
 
5
- __all__ = ["TwitterClient"]
6
+ __all__ = ["TwitterClient", "TwitterPostRequest"]
@@ -527,6 +527,92 @@ class TwitterClient(SocialMediaPlatform):
527
527
  media_type=media_type,
528
528
  ) from e
529
529
 
530
+ # ==================== Thread Methods ====================
531
+
532
+ async def create_thread(
533
+ self,
534
+ posts: list[PostCreateRequest],
535
+ ) -> list[Post]:
536
+ """Create a Twitter thread (multiple linked tweets).
537
+
538
+ Each tweet in the list can have its own content, media, polls, and alt texts.
539
+ Tweets are posted sequentially, with each tweet replying to the previous one.
540
+
541
+ Args:
542
+ posts: List of PostCreateRequest objects to create as a thread.
543
+ First tweet is the head of the thread.
544
+ Use TwitterPostRequest for Twitter-specific features.
545
+
546
+ Returns:
547
+ List of Post objects for each tweet in the thread.
548
+
549
+ Raises:
550
+ ValidationError: If posts list is empty.
551
+ PlatformAuthError: If not authenticated.
552
+ MediaUploadError: If media upload fails.
553
+ RuntimeError: If client not used as context manager.
554
+
555
+ Example:
556
+ >>> from marqetive.platforms.twitter import TwitterPostRequest
557
+ >>> tweets = [
558
+ ... TwitterPostRequest(content="Thread start! 1/3"),
559
+ ... TwitterPostRequest(content="Middle 2/3", media_urls=["..."]),
560
+ ... TwitterPostRequest(content="End 3/3", poll_options=["Yes", "No"]),
561
+ ... ]
562
+ >>> async with TwitterClient(credentials) as client:
563
+ ... thread_posts = await client.create_thread(tweets)
564
+ ... for post in thread_posts:
565
+ ... print(f"Tweet {post.post_id}: {post.content}")
566
+ """
567
+ from marqetive.core.models import ProgressStatus
568
+ from marqetive.platforms.twitter.models import TwitterPostRequest
569
+
570
+ if not posts:
571
+ raise ValidationError(
572
+ "At least one tweet is required for thread creation",
573
+ platform=self.platform_name,
574
+ )
575
+
576
+ created_posts: list[Post] = []
577
+ reply_to_id: str | None = None
578
+
579
+ for idx, post_request in enumerate(posts):
580
+ # Convert to TwitterPostRequest if needed and set reply chain
581
+ if isinstance(post_request, TwitterPostRequest):
582
+ if reply_to_id is not None:
583
+ post_request = post_request.model_copy(
584
+ update={"reply_to_post_id": reply_to_id}
585
+ )
586
+ # TwitterPostRequest works with create_post via duck typing
587
+ final_request = post_request
588
+ else:
589
+ # Create TwitterPostRequest from generic PostCreateRequest
590
+ request_data: dict[str, Any] = {
591
+ "content": post_request.content,
592
+ "media_urls": post_request.media_urls,
593
+ "media_ids": post_request.media_ids,
594
+ }
595
+ if reply_to_id is not None:
596
+ request_data["reply_to_post_id"] = reply_to_id
597
+ final_request = TwitterPostRequest(**request_data)
598
+
599
+ # TwitterPostRequest has compatible interface with PostCreateRequest
600
+ created_post = await self.create_post(final_request) # type: ignore[arg-type]
601
+ created_posts.append(created_post)
602
+ reply_to_id = created_post.post_id
603
+
604
+ # Emit progress
605
+ await self._emit_progress(
606
+ operation="create_thread",
607
+ status=ProgressStatus.PROCESSING,
608
+ progress=idx + 1,
609
+ total=len(posts),
610
+ message=f"Tweet {idx + 1}/{len(posts)} created",
611
+ entity_id=created_post.post_id,
612
+ )
613
+
614
+ return created_posts
615
+
530
616
  # ==================== Helper Methods ====================
531
617
 
532
618
  def _parse_tweet(