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.
- marqetive/__init__.py +13 -7
- marqetive/core/__init__.py +6 -4
- marqetive/core/base.py +92 -13
- marqetive/core/client.py +15 -0
- marqetive/core/models.py +111 -7
- marqetive/platforms/instagram/__init__.py +2 -1
- marqetive/platforms/instagram/client.py +29 -8
- marqetive/platforms/instagram/media.py +79 -13
- marqetive/platforms/instagram/models.py +74 -0
- marqetive/platforms/linkedin/__init__.py +51 -2
- marqetive/platforms/linkedin/client.py +978 -94
- marqetive/platforms/linkedin/media.py +156 -47
- marqetive/platforms/linkedin/models.py +413 -0
- marqetive/platforms/tiktok/__init__.py +2 -1
- marqetive/platforms/tiktok/client.py +5 -4
- marqetive/platforms/tiktok/media.py +193 -102
- marqetive/platforms/tiktok/models.py +79 -0
- marqetive/platforms/twitter/__init__.py +2 -1
- marqetive/platforms/twitter/client.py +86 -0
- marqetive/platforms/twitter/media.py +139 -70
- marqetive/platforms/twitter/models.py +58 -0
- marqetive/utils/media.py +86 -0
- marqetive/utils/oauth.py +31 -4
- {marqetive_lib-0.1.6.dist-info → marqetive_lib-0.1.8.dist-info}/METADATA +1 -9
- marqetive_lib-0.1.8.dist-info/RECORD +39 -0
- marqetive_lib-0.1.6.dist-info/RECORD +0 -35
- {marqetive_lib-0.1.6.dist-info → marqetive_lib-0.1.8.dist-info}/WHEEL +0 -0
|
@@ -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:
|
|
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
|
|
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 =
|
|
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.
|
|
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 =
|
|
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.
|
|
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 =
|
|
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
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
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
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
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 =
|
|
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
|
|
568
|
+
if file_path:
|
|
494
569
|
file_size = os.path.getsize(file_path) if os.path.exists(file_path) else 0
|
|
495
|
-
self.
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
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
|
|
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.
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
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
|
|
528
|
-
self.
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
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.
|
|
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
|
-
#
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
file_path
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
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
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
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
|
-
|
|
614
|
-
|
|
615
|
-
|
|
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
|
-
|
|
618
|
-
|
|
619
|
-
|
|
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")
|
|
@@ -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(
|