marqetive-lib 0.1.2__py3-none-any.whl → 0.1.4__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 +58 -59
- marqetive/core/__init__.py +1 -1
- marqetive/factory.py +380 -0
- marqetive/platforms/__init__.py +6 -6
- marqetive/platforms/base.py +36 -3
- marqetive/platforms/instagram/__init__.py +2 -4
- marqetive/platforms/instagram/client.py +8 -4
- marqetive/platforms/instagram/exceptions.py +1 -1
- marqetive/platforms/instagram/media.py +2 -2
- marqetive/platforms/linkedin/__init__.py +2 -4
- marqetive/platforms/linkedin/client.py +8 -4
- marqetive/platforms/linkedin/exceptions.py +1 -1
- marqetive/platforms/linkedin/media.py +4 -4
- marqetive/platforms/tiktok/__init__.py +2 -4
- marqetive/platforms/tiktok/client.py +324 -104
- marqetive/platforms/tiktok/exceptions.py +170 -66
- marqetive/platforms/tiktok/media.py +545 -159
- marqetive/platforms/twitter/__init__.py +2 -4
- marqetive/platforms/twitter/client.py +11 -53
- marqetive/platforms/twitter/exceptions.py +1 -1
- marqetive/platforms/twitter/media.py +4 -4
- marqetive/utils/__init__.py +3 -3
- marqetive/utils/file_handlers.py +1 -1
- marqetive/utils/oauth.py +2 -2
- marqetive/utils/token_validator.py +1 -1
- {marqetive_lib-0.1.2.dist-info → marqetive_lib-0.1.4.dist-info}/METADATA +1 -1
- marqetive_lib-0.1.4.dist-info/RECORD +35 -0
- marqetive/core/account_factory.py +0 -212
- marqetive/core/base_manager.py +0 -303
- marqetive/core/progress.py +0 -291
- marqetive/core/registry.py +0 -257
- marqetive/platforms/instagram/factory.py +0 -106
- marqetive/platforms/instagram/manager.py +0 -112
- marqetive/platforms/linkedin/factory.py +0 -130
- marqetive/platforms/linkedin/manager.py +0 -119
- marqetive/platforms/tiktok/factory.py +0 -188
- marqetive/platforms/tiktok/manager.py +0 -115
- marqetive/platforms/twitter/factory.py +0 -151
- marqetive/platforms/twitter/manager.py +0 -121
- marqetive/platforms/twitter/threads.py +0 -442
- marqetive/registry_init.py +0 -66
- marqetive_lib-0.1.2.dist-info/RECORD +0 -48
- {marqetive_lib-0.1.2.dist-info → marqetive_lib-0.1.4.dist-info}/WHEEL +0 -0
|
@@ -1,11 +1,17 @@
|
|
|
1
1
|
"""TikTok media upload manager for handling video uploads.
|
|
2
2
|
|
|
3
|
-
This module provides functionality for uploading videos to TikTok's API,
|
|
4
|
-
|
|
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
|
|
5
10
|
"""
|
|
6
11
|
|
|
7
12
|
import asyncio
|
|
8
13
|
import logging
|
|
14
|
+
import math
|
|
9
15
|
import os
|
|
10
16
|
from collections.abc import Callable
|
|
11
17
|
from dataclasses import dataclass
|
|
@@ -14,48 +20,61 @@ from typing import Any, Literal
|
|
|
14
20
|
|
|
15
21
|
import httpx
|
|
16
22
|
|
|
17
|
-
from
|
|
23
|
+
from marqetive.platforms.exceptions import (
|
|
18
24
|
InvalidFileTypeError,
|
|
19
25
|
MediaUploadError,
|
|
20
26
|
)
|
|
21
|
-
from
|
|
22
|
-
from
|
|
23
|
-
|
|
24
|
-
format_file_size,
|
|
25
|
-
get_chunk_count,
|
|
26
|
-
)
|
|
27
|
-
from src.marqetive.utils.retry import STANDARD_BACKOFF, retry_async
|
|
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
|
|
28
30
|
|
|
29
31
|
logger = logging.getLogger(__name__)
|
|
30
32
|
|
|
31
|
-
#
|
|
32
|
-
|
|
33
|
-
|
|
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
|
|
34
44
|
MIN_VIDEO_DURATION_SECS = 3
|
|
35
|
-
MAX_VIDEO_DURATION_SECS = 600 # 10 minutes
|
|
36
|
-
DEFAULT_REQUEST_TIMEOUT = 300.0
|
|
45
|
+
MAX_VIDEO_DURATION_SECS = 600 # 10 minutes for most videos
|
|
37
46
|
|
|
38
|
-
#
|
|
39
|
-
|
|
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
|
|
40
51
|
|
|
41
52
|
# Supported MIME types for TikTok
|
|
42
|
-
SUPPORTED_VIDEO_TYPES =
|
|
53
|
+
SUPPORTED_VIDEO_TYPES = {"video/mp4", "video/quicktime"} # MP4 and MOV
|
|
43
54
|
|
|
44
55
|
|
|
45
|
-
class
|
|
46
|
-
"""
|
|
56
|
+
class PublishStatus(str, Enum):
|
|
57
|
+
"""TikTok publish status values."""
|
|
47
58
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
FAILED = "
|
|
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
|
|
52
71
|
|
|
53
72
|
|
|
54
73
|
@dataclass
|
|
55
74
|
class UploadProgress:
|
|
56
75
|
"""Progress information for a media upload."""
|
|
57
76
|
|
|
58
|
-
|
|
77
|
+
publish_id: str | None
|
|
59
78
|
file_path: str
|
|
60
79
|
bytes_uploaded: int
|
|
61
80
|
total_bytes: int
|
|
@@ -75,21 +94,46 @@ class UploadProgress:
|
|
|
75
94
|
)
|
|
76
95
|
|
|
77
96
|
|
|
97
|
+
@dataclass
|
|
98
|
+
class UploadInitResult:
|
|
99
|
+
"""Result from initializing a video upload."""
|
|
100
|
+
|
|
101
|
+
publish_id: str
|
|
102
|
+
upload_url: str
|
|
103
|
+
|
|
104
|
+
|
|
78
105
|
@dataclass
|
|
79
106
|
class MediaUploadResult:
|
|
80
|
-
"""Result of a successful media upload."""
|
|
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
|
+
|
|
81
113
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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
|
|
86
126
|
|
|
87
127
|
|
|
88
128
|
class TikTokMediaManager:
|
|
89
|
-
"""Manages video uploads to the TikTok API.
|
|
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/
|
|
90
135
|
|
|
91
|
-
|
|
92
|
-
and monitors the processing status of the uploaded media.
|
|
136
|
+
Reference: https://developers.tiktok.com/doc/content-posting-api-media-transfer-guide
|
|
93
137
|
"""
|
|
94
138
|
|
|
95
139
|
def __init__(
|
|
@@ -100,198 +144,515 @@ class TikTokMediaManager:
|
|
|
100
144
|
progress_callback: Callable[[UploadProgress], None] | None = None,
|
|
101
145
|
timeout: float = DEFAULT_REQUEST_TIMEOUT,
|
|
102
146
|
) -> None:
|
|
103
|
-
"""Initialize the TikTok media manager.
|
|
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
|
+
"""
|
|
104
155
|
self.access_token = access_token
|
|
105
156
|
self.open_id = open_id
|
|
106
157
|
self.progress_callback = progress_callback
|
|
107
158
|
self.timeout = timeout
|
|
108
|
-
|
|
109
|
-
self.
|
|
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(
|
|
110
169
|
timeout=httpx.Timeout(timeout),
|
|
111
|
-
headers={"Authorization": f"Bearer {access_token}"},
|
|
112
170
|
)
|
|
113
171
|
|
|
114
172
|
async def __aenter__(self) -> "TikTokMediaManager":
|
|
115
173
|
return self
|
|
116
174
|
|
|
117
175
|
async def __aexit__(self, *args: Any) -> None:
|
|
118
|
-
await self.
|
|
176
|
+
await self._client.aclose()
|
|
177
|
+
await self._upload_client.aclose()
|
|
119
178
|
|
|
120
|
-
async def
|
|
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(
|
|
121
213
|
self,
|
|
122
214
|
file_path: str,
|
|
123
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,
|
|
124
222
|
chunk_size: int = DEFAULT_CHUNK_SIZE,
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
"""Upload a video file to TikTok.
|
|
223
|
+
) -> UploadInitResult:
|
|
224
|
+
"""Initialize a video upload via FILE_UPLOAD source.
|
|
128
225
|
|
|
129
226
|
Args:
|
|
130
|
-
file_path:
|
|
131
|
-
|
|
132
|
-
|
|
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.
|
|
133
235
|
|
|
134
236
|
Returns:
|
|
135
|
-
|
|
237
|
+
UploadInitResult with publish_id and upload_url.
|
|
136
238
|
|
|
137
239
|
Raises:
|
|
138
|
-
|
|
139
|
-
InvalidFileTypeError: If the file is not a supported video format.
|
|
140
|
-
MediaUploadError: If the upload or processing fails.
|
|
240
|
+
MediaUploadError: If initialization fails.
|
|
141
241
|
"""
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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
|
+
)
|
|
145
279
|
|
|
146
|
-
|
|
147
|
-
|
|
280
|
+
logger.info(f"TikTok upload initialized: publish_id={publish_id}")
|
|
281
|
+
return UploadInitResult(publish_id=publish_id, upload_url=upload_url)
|
|
148
282
|
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
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.
|
|
152
295
|
|
|
153
|
-
|
|
154
|
-
result = await self.chunked_upload(
|
|
155
|
-
file_path,
|
|
156
|
-
chunk_size=chunk_size,
|
|
157
|
-
)
|
|
296
|
+
TikTok will download the video from the provided URL.
|
|
158
297
|
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
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).
|
|
163
306
|
|
|
164
|
-
|
|
307
|
+
Returns:
|
|
308
|
+
UploadInitResult with publish_id (no upload_url for URL source).
|
|
165
309
|
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
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
|
+
)
|
|
170
343
|
|
|
171
|
-
|
|
172
|
-
|
|
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.
|
|
173
357
|
|
|
174
358
|
Args:
|
|
359
|
+
upload_url: The upload URL from init_video_upload.
|
|
175
360
|
file_path: Path to the video file.
|
|
176
|
-
|
|
361
|
+
publish_id: The publish_id for progress tracking.
|
|
362
|
+
chunk_size: Size of each chunk (5MB - 64MB).
|
|
177
363
|
|
|
178
|
-
|
|
179
|
-
|
|
364
|
+
Raises:
|
|
365
|
+
MediaUploadError: If any chunk upload fails.
|
|
180
366
|
"""
|
|
181
367
|
file_size = os.path.getsize(file_path)
|
|
182
|
-
|
|
183
|
-
|
|
368
|
+
chunk_size = self._normalize_chunk_size(chunk_size, file_size)
|
|
369
|
+
mime_type = detect_mime_type(file_path)
|
|
184
370
|
|
|
185
371
|
if self.progress_callback:
|
|
186
372
|
self.progress_callback(
|
|
187
373
|
UploadProgress(
|
|
188
|
-
|
|
374
|
+
publish_id=publish_id,
|
|
189
375
|
file_path=file_path,
|
|
190
376
|
bytes_uploaded=0,
|
|
191
377
|
total_bytes=file_size,
|
|
192
|
-
status="
|
|
378
|
+
status="uploading",
|
|
193
379
|
)
|
|
194
380
|
)
|
|
195
381
|
|
|
196
|
-
|
|
197
|
-
async def _do_upload() -> MediaUploadResult:
|
|
198
|
-
bytes_uploaded = 0
|
|
199
|
-
|
|
200
|
-
# Hypothetical: The real API may require separate INIT/APPEND/FINALIZE steps.
|
|
201
|
-
# This implementation models a single POST with all chunks.
|
|
202
|
-
async def file_iterator():
|
|
203
|
-
nonlocal bytes_uploaded
|
|
204
|
-
with open(file_path, "rb") as f:
|
|
205
|
-
for i, chunk in enumerate(iter(lambda: f.read(chunk_size), b"")):
|
|
206
|
-
bytes_uploaded += len(chunk)
|
|
207
|
-
if self.progress_callback:
|
|
208
|
-
self.progress_callback(
|
|
209
|
-
UploadProgress(
|
|
210
|
-
upload_id=upload_id,
|
|
211
|
-
file_path=file_path,
|
|
212
|
-
bytes_uploaded=bytes_uploaded,
|
|
213
|
-
total_bytes=file_size,
|
|
214
|
-
status="uploading",
|
|
215
|
-
)
|
|
216
|
-
)
|
|
217
|
-
logger.debug(f"Uploading chunk {i + 1}/{chunk_count}")
|
|
218
|
-
yield chunk
|
|
219
|
-
|
|
220
|
-
# In a real scenario, you'd likely get an upload URL first.
|
|
221
|
-
upload_url = f"{self.base_url}/upload/"
|
|
222
|
-
response = await self.client.post(
|
|
223
|
-
upload_url,
|
|
224
|
-
params={"open_id": self.open_id},
|
|
225
|
-
content=file_iterator(),
|
|
226
|
-
headers={"Content-Type": "video/mp4"},
|
|
227
|
-
)
|
|
228
|
-
response.raise_for_status()
|
|
229
|
-
result_data = response.json()
|
|
230
|
-
upload_id = result_data.get("data", {}).get("video", {}).get("video_id")
|
|
382
|
+
bytes_uploaded = 0
|
|
231
383
|
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
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}"
|
|
235
403
|
)
|
|
236
404
|
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
405
|
+
response = await self._upload_client.put(
|
|
406
|
+
upload_url,
|
|
407
|
+
content=chunk_data,
|
|
408
|
+
headers=headers,
|
|
409
|
+
)
|
|
242
410
|
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
total_bytes=file_size,
|
|
250
|
-
status="completed",
|
|
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,
|
|
251
417
|
)
|
|
252
|
-
)
|
|
253
418
|
|
|
254
|
-
|
|
255
|
-
|
|
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
|
+
)
|
|
256
432
|
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
raise MediaUploadError(
|
|
261
|
-
f"TikTok video upload failed: {e}",
|
|
262
|
-
platform="tiktok",
|
|
263
|
-
media_type=detect_mime_type(file_path),
|
|
264
|
-
) from e
|
|
433
|
+
logger.info(
|
|
434
|
+
f"TikTok video upload complete: {bytes_uploaded} bytes in {chunk_index} chunks"
|
|
435
|
+
)
|
|
265
436
|
|
|
266
|
-
async def
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
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.
|
|
270
445
|
|
|
271
|
-
|
|
446
|
+
Raises:
|
|
447
|
+
PlatformError: If the status check fails.
|
|
272
448
|
"""
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
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
|
|
278
495
|
self.progress_callback(
|
|
279
496
|
UploadProgress(
|
|
280
|
-
|
|
497
|
+
publish_id=publish_id,
|
|
281
498
|
file_path=file_path,
|
|
282
|
-
bytes_uploaded=
|
|
283
|
-
total_bytes=
|
|
499
|
+
bytes_uploaded=file_size,
|
|
500
|
+
total_bytes=file_size,
|
|
284
501
|
status="processing",
|
|
285
502
|
)
|
|
286
503
|
)
|
|
287
|
-
|
|
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
|
|
288
640
|
|
|
289
641
|
def _validate_media(self, mime_type: str, file_size: int) -> None:
|
|
290
|
-
"""Validate media type and size against TikTok's requirements.
|
|
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
|
+
"""
|
|
291
652
|
if mime_type not in SUPPORTED_VIDEO_TYPES:
|
|
292
653
|
raise InvalidFileTypeError(
|
|
293
654
|
f"Unsupported video type for TikTok: {mime_type}. "
|
|
294
|
-
f"Supported types
|
|
655
|
+
f"Supported types: {', '.join(SUPPORTED_VIDEO_TYPES)}",
|
|
295
656
|
platform="tiktok",
|
|
296
657
|
)
|
|
297
658
|
|
|
@@ -302,4 +663,29 @@ class TikTokMediaManager:
|
|
|
302
663
|
platform="tiktok",
|
|
303
664
|
media_type=mime_type,
|
|
304
665
|
)
|
|
305
|
-
|
|
666
|
+
|
|
667
|
+
def _check_response_error(self, status_code: int, data: dict[str, Any]) -> None:
|
|
668
|
+
"""Check API response for errors and raise appropriate exception.
|
|
669
|
+
|
|
670
|
+
Args:
|
|
671
|
+
status_code: HTTP status code.
|
|
672
|
+
data: Response JSON data.
|
|
673
|
+
|
|
674
|
+
Raises:
|
|
675
|
+
PlatformError: If the response indicates an error.
|
|
676
|
+
"""
|
|
677
|
+
error_data = data.get("error", {})
|
|
678
|
+
error_code = error_data.get("code", "")
|
|
679
|
+
|
|
680
|
+
# "ok" means success
|
|
681
|
+
if error_code == TikTokErrorCode.OK:
|
|
682
|
+
return
|
|
683
|
+
|
|
684
|
+
# Map to appropriate exception
|
|
685
|
+
error_message = error_data.get("message", "")
|
|
686
|
+
raise map_tiktok_error(
|
|
687
|
+
status_code=status_code,
|
|
688
|
+
error_code=error_code,
|
|
689
|
+
error_message=error_message,
|
|
690
|
+
response_data=data,
|
|
691
|
+
)
|