marqetive-lib 0.1.1__py3-none-any.whl → 0.1.3__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 -13
- marqetive/core/__init__.py +1 -1
- marqetive/core/account_factory.py +2 -2
- marqetive/core/base_manager.py +4 -4
- marqetive/core/client.py +1 -1
- marqetive/core/registry.py +3 -3
- marqetive/platforms/__init__.py +6 -6
- marqetive/platforms/base.py +3 -3
- marqetive/platforms/exceptions.py +2 -1
- marqetive/platforms/instagram/__init__.py +3 -3
- marqetive/platforms/instagram/client.py +4 -4
- marqetive/platforms/instagram/exceptions.py +1 -1
- marqetive/platforms/instagram/factory.py +5 -5
- marqetive/platforms/instagram/manager.py +4 -4
- marqetive/platforms/instagram/media.py +2 -2
- marqetive/platforms/linkedin/__init__.py +3 -3
- marqetive/platforms/linkedin/client.py +4 -4
- marqetive/platforms/linkedin/exceptions.py +1 -1
- marqetive/platforms/linkedin/factory.py +5 -5
- marqetive/platforms/linkedin/manager.py +4 -4
- marqetive/platforms/linkedin/media.py +4 -4
- marqetive/platforms/models.py +2 -0
- marqetive/platforms/tiktok/__init__.py +7 -0
- marqetive/platforms/tiktok/client.py +492 -0
- marqetive/platforms/tiktok/exceptions.py +284 -0
- marqetive/platforms/tiktok/factory.py +188 -0
- marqetive/platforms/tiktok/manager.py +115 -0
- marqetive/platforms/tiktok/media.py +693 -0
- marqetive/platforms/twitter/__init__.py +3 -3
- marqetive/platforms/twitter/client.py +8 -54
- marqetive/platforms/twitter/exceptions.py +1 -1
- marqetive/platforms/twitter/factory.py +5 -6
- marqetive/platforms/twitter/manager.py +4 -4
- marqetive/platforms/twitter/media.py +4 -4
- marqetive/registry_init.py +10 -8
- marqetive/utils/__init__.py +3 -3
- marqetive/utils/file_handlers.py +1 -1
- marqetive/utils/oauth.py +137 -2
- marqetive/utils/token_validator.py +1 -1
- {marqetive_lib-0.1.1.dist-info → marqetive_lib-0.1.3.dist-info}/METADATA +1 -2
- marqetive_lib-0.1.3.dist-info/RECORD +47 -0
- marqetive/platforms/twitter/threads.py +0 -442
- marqetive_lib-0.1.1.dist-info/RECORD +0 -43
- {marqetive_lib-0.1.1.dist-info → marqetive_lib-0.1.3.dist-info}/WHEEL +0 -0
|
@@ -0,0 +1,693 @@
|
|
|
1
|
+
"""TikTok media upload manager for handling video uploads.
|
|
2
|
+
|
|
3
|
+
This module provides functionality for uploading videos to TikTok's Content Posting API v2,
|
|
4
|
+
implementing the official upload flow:
|
|
5
|
+
1. POST /post/publish/video/init/ - Initialize upload, get publish_id and upload_url
|
|
6
|
+
2. PUT {upload_url} - Upload video chunks with Content-Range header
|
|
7
|
+
3. POST /post/publish/status/fetch/ - Poll until PUBLISH_COMPLETE
|
|
8
|
+
|
|
9
|
+
Reference: https://developers.tiktok.com/doc/content-posting-api-reference-direct-post
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import asyncio
|
|
13
|
+
import logging
|
|
14
|
+
import math
|
|
15
|
+
import os
|
|
16
|
+
from collections.abc import Callable
|
|
17
|
+
from dataclasses import dataclass
|
|
18
|
+
from enum import Enum
|
|
19
|
+
from typing import Any, Literal
|
|
20
|
+
|
|
21
|
+
import httpx
|
|
22
|
+
|
|
23
|
+
from marqetive.platforms.exceptions import (
|
|
24
|
+
InvalidFileTypeError,
|
|
25
|
+
MediaUploadError,
|
|
26
|
+
)
|
|
27
|
+
from marqetive.platforms.tiktok.exceptions import TikTokErrorCode, map_tiktok_error
|
|
28
|
+
from marqetive.utils.file_handlers import download_file
|
|
29
|
+
from marqetive.utils.media import detect_mime_type, format_file_size
|
|
30
|
+
|
|
31
|
+
logger = logging.getLogger(__name__)
|
|
32
|
+
|
|
33
|
+
# TikTok API Base URLs
|
|
34
|
+
TIKTOK_API_BASE = "https://open.tiktokapis.com/v2"
|
|
35
|
+
|
|
36
|
+
# Chunk size limits per TikTok documentation
|
|
37
|
+
MIN_CHUNK_SIZE = 5 * 1024 * 1024 # 5 MB minimum
|
|
38
|
+
MAX_CHUNK_SIZE = 64 * 1024 * 1024 # 64 MB maximum
|
|
39
|
+
FINAL_CHUNK_MAX = 128 * 1024 * 1024 # Final chunk can be up to 128 MB
|
|
40
|
+
DEFAULT_CHUNK_SIZE = 10 * 1024 * 1024 # 10 MB default
|
|
41
|
+
|
|
42
|
+
# Video size and duration limits
|
|
43
|
+
MAX_VIDEO_SIZE = 4 * 1024 * 1024 * 1024 # 4 GB max for direct post
|
|
44
|
+
MIN_VIDEO_DURATION_SECS = 3
|
|
45
|
+
MAX_VIDEO_DURATION_SECS = 600 # 10 minutes for most videos
|
|
46
|
+
|
|
47
|
+
# Timeouts
|
|
48
|
+
DEFAULT_REQUEST_TIMEOUT = 300.0
|
|
49
|
+
STATUS_POLL_INTERVAL = 5.0 # Poll every 5 seconds
|
|
50
|
+
MAX_PROCESSING_TIME = 600.0 # 10 minutes max wait for processing
|
|
51
|
+
|
|
52
|
+
# Supported MIME types for TikTok
|
|
53
|
+
SUPPORTED_VIDEO_TYPES = {"video/mp4", "video/quicktime"} # MP4 and MOV
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class PublishStatus(str, Enum):
|
|
57
|
+
"""TikTok publish status values."""
|
|
58
|
+
|
|
59
|
+
PROCESSING_UPLOAD = "PROCESSING_UPLOAD"
|
|
60
|
+
PROCESSING_DOWNLOAD = "PROCESSING_DOWNLOAD"
|
|
61
|
+
PUBLISH_COMPLETE = "PUBLISH_COMPLETE"
|
|
62
|
+
FAILED = "FAILED"
|
|
63
|
+
|
|
64
|
+
|
|
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
|
+
@dataclass
|
|
74
|
+
class UploadProgress:
|
|
75
|
+
"""Progress information for a media upload."""
|
|
76
|
+
|
|
77
|
+
publish_id: str | None
|
|
78
|
+
file_path: str
|
|
79
|
+
bytes_uploaded: int
|
|
80
|
+
total_bytes: int
|
|
81
|
+
status: Literal["init", "uploading", "processing", "completed", "failed"]
|
|
82
|
+
|
|
83
|
+
@property
|
|
84
|
+
def percentage(self) -> float:
|
|
85
|
+
if self.total_bytes == 0:
|
|
86
|
+
return 0.0
|
|
87
|
+
return (self.bytes_uploaded / self.total_bytes) * 100
|
|
88
|
+
|
|
89
|
+
def __str__(self) -> str:
|
|
90
|
+
return (
|
|
91
|
+
f"Upload Progress: {self.percentage:.1f}% "
|
|
92
|
+
f"({format_file_size(self.bytes_uploaded)} / "
|
|
93
|
+
f"{format_file_size(self.total_bytes)}) - {self.status}"
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@dataclass
|
|
98
|
+
class UploadInitResult:
|
|
99
|
+
"""Result from initializing a video upload."""
|
|
100
|
+
|
|
101
|
+
publish_id: str
|
|
102
|
+
upload_url: str
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
@dataclass
|
|
106
|
+
class MediaUploadResult:
|
|
107
|
+
"""Result of a successful media upload and publish."""
|
|
108
|
+
|
|
109
|
+
publish_id: str
|
|
110
|
+
video_id: str | None = None # Available after PUBLISH_COMPLETE
|
|
111
|
+
status: str = PublishStatus.PROCESSING_UPLOAD
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
@dataclass
|
|
115
|
+
class CreatorInfo:
|
|
116
|
+
"""Creator info returned from query_creator_info endpoint."""
|
|
117
|
+
|
|
118
|
+
creator_avatar_url: str | None = None
|
|
119
|
+
creator_username: str | None = None
|
|
120
|
+
creator_nickname: str | None = None
|
|
121
|
+
privacy_level_options: list[str] | None = None
|
|
122
|
+
comment_disabled: bool = False
|
|
123
|
+
duet_disabled: bool = False
|
|
124
|
+
stitch_disabled: bool = False
|
|
125
|
+
max_video_post_duration_sec: int = 600
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
class TikTokMediaManager:
|
|
129
|
+
"""Manages video uploads to the TikTok Content Posting API v2.
|
|
130
|
+
|
|
131
|
+
This class implements the official TikTok upload flow:
|
|
132
|
+
1. Initialize upload via POST /post/publish/video/init/
|
|
133
|
+
2. Upload video chunks via PUT to upload_url
|
|
134
|
+
3. Poll status via POST /post/publish/status/fetch/
|
|
135
|
+
|
|
136
|
+
Reference: https://developers.tiktok.com/doc/content-posting-api-media-transfer-guide
|
|
137
|
+
"""
|
|
138
|
+
|
|
139
|
+
def __init__(
|
|
140
|
+
self,
|
|
141
|
+
access_token: str,
|
|
142
|
+
open_id: str,
|
|
143
|
+
*,
|
|
144
|
+
progress_callback: Callable[[UploadProgress], None] | None = None,
|
|
145
|
+
timeout: float = DEFAULT_REQUEST_TIMEOUT,
|
|
146
|
+
) -> None:
|
|
147
|
+
"""Initialize the TikTok media manager.
|
|
148
|
+
|
|
149
|
+
Args:
|
|
150
|
+
access_token: OAuth access token with video.publish scope.
|
|
151
|
+
open_id: User's open_id from OAuth flow.
|
|
152
|
+
progress_callback: Optional callback for upload progress updates.
|
|
153
|
+
timeout: Request timeout in seconds.
|
|
154
|
+
"""
|
|
155
|
+
self.access_token = access_token
|
|
156
|
+
self.open_id = open_id
|
|
157
|
+
self.progress_callback = progress_callback
|
|
158
|
+
self.timeout = timeout
|
|
159
|
+
|
|
160
|
+
self._client = httpx.AsyncClient(
|
|
161
|
+
timeout=httpx.Timeout(timeout),
|
|
162
|
+
headers={
|
|
163
|
+
"Authorization": f"Bearer {access_token}",
|
|
164
|
+
"Content-Type": "application/json; charset=UTF-8",
|
|
165
|
+
},
|
|
166
|
+
)
|
|
167
|
+
# Separate client for uploads (no JSON content type)
|
|
168
|
+
self._upload_client = httpx.AsyncClient(
|
|
169
|
+
timeout=httpx.Timeout(timeout),
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
async def __aenter__(self) -> "TikTokMediaManager":
|
|
173
|
+
return self
|
|
174
|
+
|
|
175
|
+
async def __aexit__(self, *args: Any) -> None:
|
|
176
|
+
await self._client.aclose()
|
|
177
|
+
await self._upload_client.aclose()
|
|
178
|
+
|
|
179
|
+
async def query_creator_info(self) -> CreatorInfo:
|
|
180
|
+
"""Query creator info before posting.
|
|
181
|
+
|
|
182
|
+
This endpoint MUST be called before creating a post to get
|
|
183
|
+
available privacy levels and posting limits.
|
|
184
|
+
|
|
185
|
+
Returns:
|
|
186
|
+
CreatorInfo with available options for this creator.
|
|
187
|
+
|
|
188
|
+
Raises:
|
|
189
|
+
PlatformError: If the request fails.
|
|
190
|
+
"""
|
|
191
|
+
url = f"{TIKTOK_API_BASE}/post/publish/creator_info/query/"
|
|
192
|
+
|
|
193
|
+
response = await self._client.post(url)
|
|
194
|
+
data = response.json()
|
|
195
|
+
|
|
196
|
+
self._check_response_error(response.status_code, data)
|
|
197
|
+
|
|
198
|
+
creator_data = data.get("data", {})
|
|
199
|
+
return CreatorInfo(
|
|
200
|
+
creator_avatar_url=creator_data.get("creator_avatar_url"),
|
|
201
|
+
creator_username=creator_data.get("creator_username"),
|
|
202
|
+
creator_nickname=creator_data.get("creator_nickname"),
|
|
203
|
+
privacy_level_options=creator_data.get("privacy_level_options", []),
|
|
204
|
+
comment_disabled=creator_data.get("comment_disabled", False),
|
|
205
|
+
duet_disabled=creator_data.get("duet_disabled", False),
|
|
206
|
+
stitch_disabled=creator_data.get("stitch_disabled", False),
|
|
207
|
+
max_video_post_duration_sec=creator_data.get(
|
|
208
|
+
"max_video_post_duration_sec", 600
|
|
209
|
+
),
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
async def init_video_upload(
|
|
213
|
+
self,
|
|
214
|
+
file_path: str,
|
|
215
|
+
*,
|
|
216
|
+
title: str = "",
|
|
217
|
+
privacy_level: PrivacyLevel = PrivacyLevel.SELF_ONLY,
|
|
218
|
+
disable_duet: bool = False,
|
|
219
|
+
disable_comment: bool = False,
|
|
220
|
+
disable_stitch: bool = False,
|
|
221
|
+
video_cover_timestamp_ms: int = 1000,
|
|
222
|
+
chunk_size: int = DEFAULT_CHUNK_SIZE,
|
|
223
|
+
) -> UploadInitResult:
|
|
224
|
+
"""Initialize a video upload via FILE_UPLOAD source.
|
|
225
|
+
|
|
226
|
+
Args:
|
|
227
|
+
file_path: Path to the video file.
|
|
228
|
+
title: Video title/description (with hashtags, mentions).
|
|
229
|
+
privacy_level: Privacy setting for the video.
|
|
230
|
+
disable_duet: Disable duet for this video.
|
|
231
|
+
disable_comment: Disable comments for this video.
|
|
232
|
+
disable_stitch: Disable stitch for this video.
|
|
233
|
+
video_cover_timestamp_ms: Timestamp for cover image (ms).
|
|
234
|
+
chunk_size: Size of each upload chunk.
|
|
235
|
+
|
|
236
|
+
Returns:
|
|
237
|
+
UploadInitResult with publish_id and upload_url.
|
|
238
|
+
|
|
239
|
+
Raises:
|
|
240
|
+
MediaUploadError: If initialization fails.
|
|
241
|
+
"""
|
|
242
|
+
file_size = os.path.getsize(file_path)
|
|
243
|
+
chunk_size = self._normalize_chunk_size(chunk_size, file_size)
|
|
244
|
+
total_chunks = math.ceil(file_size / chunk_size)
|
|
245
|
+
|
|
246
|
+
url = f"{TIKTOK_API_BASE}/post/publish/video/init/"
|
|
247
|
+
|
|
248
|
+
payload = {
|
|
249
|
+
"post_info": {
|
|
250
|
+
"title": title,
|
|
251
|
+
"privacy_level": privacy_level.value,
|
|
252
|
+
"disable_duet": disable_duet,
|
|
253
|
+
"disable_comment": disable_comment,
|
|
254
|
+
"disable_stitch": disable_stitch,
|
|
255
|
+
"video_cover_timestamp_ms": video_cover_timestamp_ms,
|
|
256
|
+
},
|
|
257
|
+
"source_info": {
|
|
258
|
+
"source": "FILE_UPLOAD",
|
|
259
|
+
"video_size": file_size,
|
|
260
|
+
"chunk_size": chunk_size,
|
|
261
|
+
"total_chunk_count": total_chunks,
|
|
262
|
+
},
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
response = await self._client.post(url, json=payload)
|
|
266
|
+
data = response.json()
|
|
267
|
+
|
|
268
|
+
self._check_response_error(response.status_code, data)
|
|
269
|
+
|
|
270
|
+
result_data = data.get("data", {})
|
|
271
|
+
publish_id = result_data.get("publish_id")
|
|
272
|
+
upload_url = result_data.get("upload_url")
|
|
273
|
+
|
|
274
|
+
if not publish_id or not upload_url:
|
|
275
|
+
raise MediaUploadError(
|
|
276
|
+
"Upload initialization succeeded but missing publish_id or upload_url",
|
|
277
|
+
platform="tiktok",
|
|
278
|
+
)
|
|
279
|
+
|
|
280
|
+
logger.info(f"TikTok upload initialized: publish_id={publish_id}")
|
|
281
|
+
return UploadInitResult(publish_id=publish_id, upload_url=upload_url)
|
|
282
|
+
|
|
283
|
+
async def init_url_upload(
|
|
284
|
+
self,
|
|
285
|
+
video_url: str,
|
|
286
|
+
*,
|
|
287
|
+
title: str = "",
|
|
288
|
+
privacy_level: PrivacyLevel = PrivacyLevel.SELF_ONLY,
|
|
289
|
+
disable_duet: bool = False,
|
|
290
|
+
disable_comment: bool = False,
|
|
291
|
+
disable_stitch: bool = False,
|
|
292
|
+
video_cover_timestamp_ms: int = 1000,
|
|
293
|
+
) -> UploadInitResult:
|
|
294
|
+
"""Initialize a video upload via PULL_FROM_URL source.
|
|
295
|
+
|
|
296
|
+
TikTok will download the video from the provided URL.
|
|
297
|
+
|
|
298
|
+
Args:
|
|
299
|
+
video_url: Public URL of the video to upload.
|
|
300
|
+
title: Video title/description.
|
|
301
|
+
privacy_level: Privacy setting for the video.
|
|
302
|
+
disable_duet: Disable duet for this video.
|
|
303
|
+
disable_comment: Disable comments for this video.
|
|
304
|
+
disable_stitch: Disable stitch for this video.
|
|
305
|
+
video_cover_timestamp_ms: Timestamp for cover image (ms).
|
|
306
|
+
|
|
307
|
+
Returns:
|
|
308
|
+
UploadInitResult with publish_id (no upload_url for URL source).
|
|
309
|
+
|
|
310
|
+
Raises:
|
|
311
|
+
MediaUploadError: If initialization fails.
|
|
312
|
+
"""
|
|
313
|
+
url = f"{TIKTOK_API_BASE}/post/publish/video/init/"
|
|
314
|
+
|
|
315
|
+
payload = {
|
|
316
|
+
"post_info": {
|
|
317
|
+
"title": title,
|
|
318
|
+
"privacy_level": privacy_level.value,
|
|
319
|
+
"disable_duet": disable_duet,
|
|
320
|
+
"disable_comment": disable_comment,
|
|
321
|
+
"disable_stitch": disable_stitch,
|
|
322
|
+
"video_cover_timestamp_ms": video_cover_timestamp_ms,
|
|
323
|
+
},
|
|
324
|
+
"source_info": {
|
|
325
|
+
"source": "PULL_FROM_URL",
|
|
326
|
+
"video_url": video_url,
|
|
327
|
+
},
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
response = await self._client.post(url, json=payload)
|
|
331
|
+
data = response.json()
|
|
332
|
+
|
|
333
|
+
self._check_response_error(response.status_code, data)
|
|
334
|
+
|
|
335
|
+
result_data = data.get("data", {})
|
|
336
|
+
publish_id = result_data.get("publish_id")
|
|
337
|
+
|
|
338
|
+
if not publish_id:
|
|
339
|
+
raise MediaUploadError(
|
|
340
|
+
"URL upload initialization succeeded but missing publish_id",
|
|
341
|
+
platform="tiktok",
|
|
342
|
+
)
|
|
343
|
+
|
|
344
|
+
logger.info(f"TikTok URL upload initialized: publish_id={publish_id}")
|
|
345
|
+
return UploadInitResult(publish_id=publish_id, upload_url="")
|
|
346
|
+
|
|
347
|
+
async def upload_video_chunks(
|
|
348
|
+
self,
|
|
349
|
+
upload_url: str,
|
|
350
|
+
file_path: str,
|
|
351
|
+
publish_id: str,
|
|
352
|
+
chunk_size: int = DEFAULT_CHUNK_SIZE,
|
|
353
|
+
) -> None:
|
|
354
|
+
"""Upload video file in chunks to the upload_url.
|
|
355
|
+
|
|
356
|
+
Uses PUT requests with Content-Range header as per TikTok spec.
|
|
357
|
+
|
|
358
|
+
Args:
|
|
359
|
+
upload_url: The upload URL from init_video_upload.
|
|
360
|
+
file_path: Path to the video file.
|
|
361
|
+
publish_id: The publish_id for progress tracking.
|
|
362
|
+
chunk_size: Size of each chunk (5MB - 64MB).
|
|
363
|
+
|
|
364
|
+
Raises:
|
|
365
|
+
MediaUploadError: If any chunk upload fails.
|
|
366
|
+
"""
|
|
367
|
+
file_size = os.path.getsize(file_path)
|
|
368
|
+
chunk_size = self._normalize_chunk_size(chunk_size, file_size)
|
|
369
|
+
mime_type = detect_mime_type(file_path)
|
|
370
|
+
|
|
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
|
+
)
|
|
381
|
+
|
|
382
|
+
bytes_uploaded = 0
|
|
383
|
+
|
|
384
|
+
with open(file_path, "rb") as f:
|
|
385
|
+
chunk_index = 0
|
|
386
|
+
while True:
|
|
387
|
+
chunk_data = f.read(chunk_size)
|
|
388
|
+
if not chunk_data:
|
|
389
|
+
break
|
|
390
|
+
|
|
391
|
+
chunk_start = bytes_uploaded
|
|
392
|
+
chunk_end = bytes_uploaded + len(chunk_data) - 1
|
|
393
|
+
|
|
394
|
+
headers = {
|
|
395
|
+
"Content-Type": mime_type,
|
|
396
|
+
"Content-Range": f"bytes {chunk_start}-{chunk_end}/{file_size}",
|
|
397
|
+
"Content-Length": str(len(chunk_data)),
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
logger.debug(
|
|
401
|
+
f"Uploading chunk {chunk_index + 1}: "
|
|
402
|
+
f"bytes {chunk_start}-{chunk_end}/{file_size}"
|
|
403
|
+
)
|
|
404
|
+
|
|
405
|
+
response = await self._upload_client.put(
|
|
406
|
+
upload_url,
|
|
407
|
+
content=chunk_data,
|
|
408
|
+
headers=headers,
|
|
409
|
+
)
|
|
410
|
+
|
|
411
|
+
if response.status_code not in (200, 201, 206):
|
|
412
|
+
raise MediaUploadError(
|
|
413
|
+
f"Chunk upload failed with status {response.status_code}: "
|
|
414
|
+
f"{response.text}",
|
|
415
|
+
platform="tiktok",
|
|
416
|
+
status_code=response.status_code,
|
|
417
|
+
)
|
|
418
|
+
|
|
419
|
+
bytes_uploaded += len(chunk_data)
|
|
420
|
+
chunk_index += 1
|
|
421
|
+
|
|
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
|
+
)
|
|
432
|
+
|
|
433
|
+
logger.info(
|
|
434
|
+
f"TikTok video upload complete: {bytes_uploaded} bytes in {chunk_index} chunks"
|
|
435
|
+
)
|
|
436
|
+
|
|
437
|
+
async def check_publish_status(self, publish_id: str) -> MediaUploadResult:
|
|
438
|
+
"""Check the publish status of an upload.
|
|
439
|
+
|
|
440
|
+
Args:
|
|
441
|
+
publish_id: The publish_id from upload initialization.
|
|
442
|
+
|
|
443
|
+
Returns:
|
|
444
|
+
MediaUploadResult with current status and video_id if complete.
|
|
445
|
+
|
|
446
|
+
Raises:
|
|
447
|
+
PlatformError: If the status check fails.
|
|
448
|
+
"""
|
|
449
|
+
url = f"{TIKTOK_API_BASE}/post/publish/status/fetch/"
|
|
450
|
+
|
|
451
|
+
response = await self._client.post(url, json={"publish_id": publish_id})
|
|
452
|
+
data = response.json()
|
|
453
|
+
|
|
454
|
+
self._check_response_error(response.status_code, data)
|
|
455
|
+
|
|
456
|
+
result_data = data.get("data", {})
|
|
457
|
+
status = result_data.get("status", PublishStatus.FAILED)
|
|
458
|
+
|
|
459
|
+
video_id = None
|
|
460
|
+
if status == PublishStatus.PUBLISH_COMPLETE:
|
|
461
|
+
# Video IDs are in publicaly_available_post_id array
|
|
462
|
+
video_ids = result_data.get("publicaly_available_post_id", [])
|
|
463
|
+
if video_ids:
|
|
464
|
+
video_id = str(video_ids[0])
|
|
465
|
+
|
|
466
|
+
return MediaUploadResult(
|
|
467
|
+
publish_id=publish_id,
|
|
468
|
+
video_id=video_id,
|
|
469
|
+
status=status,
|
|
470
|
+
)
|
|
471
|
+
|
|
472
|
+
async def wait_for_publish(
|
|
473
|
+
self,
|
|
474
|
+
publish_id: str,
|
|
475
|
+
file_path: str = "",
|
|
476
|
+
max_wait: float = MAX_PROCESSING_TIME,
|
|
477
|
+
) -> MediaUploadResult:
|
|
478
|
+
"""Wait for a video to finish publishing.
|
|
479
|
+
|
|
480
|
+
Polls the status endpoint until PUBLISH_COMPLETE or FAILED.
|
|
481
|
+
|
|
482
|
+
Args:
|
|
483
|
+
publish_id: The publish_id from upload initialization.
|
|
484
|
+
file_path: Original file path for progress callbacks.
|
|
485
|
+
max_wait: Maximum time to wait in seconds.
|
|
486
|
+
|
|
487
|
+
Returns:
|
|
488
|
+
MediaUploadResult with final status and video_id.
|
|
489
|
+
|
|
490
|
+
Raises:
|
|
491
|
+
MediaUploadError: If publishing fails or times out.
|
|
492
|
+
"""
|
|
493
|
+
if self.progress_callback and file_path:
|
|
494
|
+
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
|
+
)
|
|
503
|
+
)
|
|
504
|
+
|
|
505
|
+
elapsed = 0.0
|
|
506
|
+
while elapsed < max_wait:
|
|
507
|
+
result = await self.check_publish_status(publish_id)
|
|
508
|
+
|
|
509
|
+
if result.status == PublishStatus.PUBLISH_COMPLETE:
|
|
510
|
+
logger.info(f"TikTok video published: video_id={result.video_id}")
|
|
511
|
+
if self.progress_callback and file_path:
|
|
512
|
+
file_size = (
|
|
513
|
+
os.path.getsize(file_path) if os.path.exists(file_path) else 0
|
|
514
|
+
)
|
|
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
|
+
)
|
|
523
|
+
)
|
|
524
|
+
return result
|
|
525
|
+
|
|
526
|
+
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
|
+
)
|
|
536
|
+
)
|
|
537
|
+
raise MediaUploadError(
|
|
538
|
+
f"TikTok video publishing failed: publish_id={publish_id}",
|
|
539
|
+
platform="tiktok",
|
|
540
|
+
)
|
|
541
|
+
|
|
542
|
+
logger.debug(f"Publish status: {result.status}, waiting...")
|
|
543
|
+
await asyncio.sleep(STATUS_POLL_INTERVAL)
|
|
544
|
+
elapsed += STATUS_POLL_INTERVAL
|
|
545
|
+
|
|
546
|
+
raise MediaUploadError(
|
|
547
|
+
f"Timed out waiting for video to publish after {max_wait}s",
|
|
548
|
+
platform="tiktok",
|
|
549
|
+
)
|
|
550
|
+
|
|
551
|
+
async def upload_media(
|
|
552
|
+
self,
|
|
553
|
+
file_path: str,
|
|
554
|
+
*,
|
|
555
|
+
title: str = "",
|
|
556
|
+
privacy_level: PrivacyLevel = PrivacyLevel.SELF_ONLY,
|
|
557
|
+
chunk_size: int = DEFAULT_CHUNK_SIZE,
|
|
558
|
+
wait_for_publish: bool = True,
|
|
559
|
+
) -> MediaUploadResult:
|
|
560
|
+
"""Upload a video file to TikTok (full flow).
|
|
561
|
+
|
|
562
|
+
This is the main entry point that handles the complete upload flow:
|
|
563
|
+
1. Download if URL
|
|
564
|
+
2. Validate file
|
|
565
|
+
3. Initialize upload
|
|
566
|
+
4. Upload chunks
|
|
567
|
+
5. Wait for publish (optional)
|
|
568
|
+
|
|
569
|
+
Args:
|
|
570
|
+
file_path: Local path or URL of the video to upload.
|
|
571
|
+
title: Video title/description with hashtags.
|
|
572
|
+
privacy_level: Privacy setting for the video.
|
|
573
|
+
chunk_size: Size of each upload chunk.
|
|
574
|
+
wait_for_publish: If True, wait until video is published.
|
|
575
|
+
|
|
576
|
+
Returns:
|
|
577
|
+
MediaUploadResult with publish_id and video_id (if published).
|
|
578
|
+
|
|
579
|
+
Raises:
|
|
580
|
+
FileNotFoundError: If the local file does not exist.
|
|
581
|
+
InvalidFileTypeError: If the file is not a supported video format.
|
|
582
|
+
MediaUploadError: If upload or processing fails.
|
|
583
|
+
"""
|
|
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
|
+
)
|
|
604
|
+
|
|
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
|
+
)
|
|
612
|
+
|
|
613
|
+
# Wait for publish
|
|
614
|
+
if wait_for_publish:
|
|
615
|
+
return await self.wait_for_publish(init_result.publish_id, file_path)
|
|
616
|
+
|
|
617
|
+
return MediaUploadResult(
|
|
618
|
+
publish_id=init_result.publish_id,
|
|
619
|
+
status=PublishStatus.PROCESSING_UPLOAD,
|
|
620
|
+
)
|
|
621
|
+
|
|
622
|
+
def _normalize_chunk_size(self, chunk_size: int, file_size: int) -> int:
|
|
623
|
+
"""Normalize chunk size to TikTok's requirements.
|
|
624
|
+
|
|
625
|
+
Args:
|
|
626
|
+
chunk_size: Requested chunk size.
|
|
627
|
+
file_size: Total file size.
|
|
628
|
+
|
|
629
|
+
Returns:
|
|
630
|
+
Normalized chunk size within TikTok limits.
|
|
631
|
+
"""
|
|
632
|
+
# Files smaller than MIN_CHUNK_SIZE must be uploaded as single chunk
|
|
633
|
+
if file_size < MIN_CHUNK_SIZE:
|
|
634
|
+
return file_size
|
|
635
|
+
|
|
636
|
+
# Ensure within limits
|
|
637
|
+
chunk_size = max(MIN_CHUNK_SIZE, min(chunk_size, MAX_CHUNK_SIZE))
|
|
638
|
+
|
|
639
|
+
return chunk_size
|
|
640
|
+
|
|
641
|
+
def _validate_media(self, mime_type: str, file_size: int) -> None:
|
|
642
|
+
"""Validate media type and size against TikTok's requirements.
|
|
643
|
+
|
|
644
|
+
Args:
|
|
645
|
+
mime_type: MIME type of the file.
|
|
646
|
+
file_size: Size of the file in bytes.
|
|
647
|
+
|
|
648
|
+
Raises:
|
|
649
|
+
InvalidFileTypeError: If MIME type is not supported.
|
|
650
|
+
MediaUploadError: If file size exceeds limits.
|
|
651
|
+
"""
|
|
652
|
+
if mime_type not in SUPPORTED_VIDEO_TYPES:
|
|
653
|
+
raise InvalidFileTypeError(
|
|
654
|
+
f"Unsupported video type for TikTok: {mime_type}. "
|
|
655
|
+
f"Supported types: {', '.join(SUPPORTED_VIDEO_TYPES)}",
|
|
656
|
+
platform="tiktok",
|
|
657
|
+
)
|
|
658
|
+
|
|
659
|
+
if file_size > MAX_VIDEO_SIZE:
|
|
660
|
+
raise MediaUploadError(
|
|
661
|
+
f"Video file size ({format_file_size(file_size)}) exceeds the "
|
|
662
|
+
f"TikTok limit of {format_file_size(MAX_VIDEO_SIZE)}",
|
|
663
|
+
platform="tiktok",
|
|
664
|
+
media_type=mime_type,
|
|
665
|
+
)
|
|
666
|
+
|
|
667
|
+
def _check_response_error(
|
|
668
|
+
self, status_code: int, data: dict[str, Any]
|
|
669
|
+
) -> None:
|
|
670
|
+
"""Check API response for errors and raise appropriate exception.
|
|
671
|
+
|
|
672
|
+
Args:
|
|
673
|
+
status_code: HTTP status code.
|
|
674
|
+
data: Response JSON data.
|
|
675
|
+
|
|
676
|
+
Raises:
|
|
677
|
+
PlatformError: If the response indicates an error.
|
|
678
|
+
"""
|
|
679
|
+
error_data = data.get("error", {})
|
|
680
|
+
error_code = error_data.get("code", "")
|
|
681
|
+
|
|
682
|
+
# "ok" means success
|
|
683
|
+
if error_code == TikTokErrorCode.OK:
|
|
684
|
+
return
|
|
685
|
+
|
|
686
|
+
# Map to appropriate exception
|
|
687
|
+
error_message = error_data.get("message", "")
|
|
688
|
+
raise map_tiktok_error(
|
|
689
|
+
status_code=status_code,
|
|
690
|
+
error_code=error_code,
|
|
691
|
+
error_message=error_message,
|
|
692
|
+
response_data=data,
|
|
693
|
+
)
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
"""Twitter/X platform integration."""
|
|
2
2
|
|
|
3
|
-
from
|
|
4
|
-
from
|
|
5
|
-
from
|
|
3
|
+
from marqetive.platforms.twitter.client import TwitterClient
|
|
4
|
+
from marqetive.platforms.twitter.factory import TwitterAccountFactory
|
|
5
|
+
from marqetive.platforms.twitter.manager import TwitterPostManager
|
|
6
6
|
|
|
7
7
|
__all__ = ["TwitterClient", "TwitterAccountFactory", "TwitterPostManager"]
|