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.
Files changed (43) hide show
  1. marqetive/__init__.py +58 -59
  2. marqetive/core/__init__.py +1 -1
  3. marqetive/factory.py +380 -0
  4. marqetive/platforms/__init__.py +6 -6
  5. marqetive/platforms/base.py +36 -3
  6. marqetive/platforms/instagram/__init__.py +2 -4
  7. marqetive/platforms/instagram/client.py +8 -4
  8. marqetive/platforms/instagram/exceptions.py +1 -1
  9. marqetive/platforms/instagram/media.py +2 -2
  10. marqetive/platforms/linkedin/__init__.py +2 -4
  11. marqetive/platforms/linkedin/client.py +8 -4
  12. marqetive/platforms/linkedin/exceptions.py +1 -1
  13. marqetive/platforms/linkedin/media.py +4 -4
  14. marqetive/platforms/tiktok/__init__.py +2 -4
  15. marqetive/platforms/tiktok/client.py +324 -104
  16. marqetive/platforms/tiktok/exceptions.py +170 -66
  17. marqetive/platforms/tiktok/media.py +545 -159
  18. marqetive/platforms/twitter/__init__.py +2 -4
  19. marqetive/platforms/twitter/client.py +11 -53
  20. marqetive/platforms/twitter/exceptions.py +1 -1
  21. marqetive/platforms/twitter/media.py +4 -4
  22. marqetive/utils/__init__.py +3 -3
  23. marqetive/utils/file_handlers.py +1 -1
  24. marqetive/utils/oauth.py +2 -2
  25. marqetive/utils/token_validator.py +1 -1
  26. {marqetive_lib-0.1.2.dist-info → marqetive_lib-0.1.4.dist-info}/METADATA +1 -1
  27. marqetive_lib-0.1.4.dist-info/RECORD +35 -0
  28. marqetive/core/account_factory.py +0 -212
  29. marqetive/core/base_manager.py +0 -303
  30. marqetive/core/progress.py +0 -291
  31. marqetive/core/registry.py +0 -257
  32. marqetive/platforms/instagram/factory.py +0 -106
  33. marqetive/platforms/instagram/manager.py +0 -112
  34. marqetive/platforms/linkedin/factory.py +0 -130
  35. marqetive/platforms/linkedin/manager.py +0 -119
  36. marqetive/platforms/tiktok/factory.py +0 -188
  37. marqetive/platforms/tiktok/manager.py +0 -115
  38. marqetive/platforms/twitter/factory.py +0 -151
  39. marqetive/platforms/twitter/manager.py +0 -121
  40. marqetive/platforms/twitter/threads.py +0 -442
  41. marqetive/registry_init.py +0 -66
  42. marqetive_lib-0.1.2.dist-info/RECORD +0 -48
  43. {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
- focusing on a chunked upload process suitable for large video files.
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 src.marqetive.platforms.exceptions import (
23
+ from marqetive.platforms.exceptions import (
18
24
  InvalidFileTypeError,
19
25
  MediaUploadError,
20
26
  )
21
- from src.marqetive.utils.file_handlers import download_file
22
- from src.marqetive.utils.media import (
23
- detect_mime_type,
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
- # Constants for TikTok Media Upload
32
- DEFAULT_CHUNK_SIZE = 5 * 1024 * 1024 # 5MB chunks
33
- MAX_VIDEO_SIZE = 10 * 1024 * 1024 * 1024 # 10GB, hypothetical
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
- # Hypothetical TikTok API endpoints for media
39
- MEDIA_API_BASE_URL = "https://open.tiktokapis.com/v2/video"
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 = ["video/mp4", "video/quicktime"] # MOV
53
+ SUPPORTED_VIDEO_TYPES = {"video/mp4", "video/quicktime"} # MP4 and MOV
43
54
 
44
55
 
45
- class ProcessingState(str, Enum):
46
- """States for async media processing."""
56
+ class PublishStatus(str, Enum):
57
+ """TikTok publish status values."""
47
58
 
48
- PENDING = "pending"
49
- PROCESSING = "processing"
50
- READY = "ready"
51
- FAILED = "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
- upload_id: str | None
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
- upload_id: str
83
- media_id: str | None = None # Available after processing
84
- size: int | None = None
85
- processing_info: dict[str, Any] | None = None
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
- This class handles the complexities of chunked uploads for large video files
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
- self.base_url = MEDIA_API_BASE_URL
109
- self.client = httpx.AsyncClient(
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.client.aclose()
176
+ await self._client.aclose()
177
+ await self._upload_client.aclose()
119
178
 
120
- async def upload_media(
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
- wait_for_processing: bool = True,
126
- ) -> MediaUploadResult:
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: The local path or URL of the video to upload.
131
- chunk_size: The size of each chunk to upload in bytes.
132
- wait_for_processing: If True, wait until the video is processed and ready.
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
- A MediaUploadResult containing the upload and media IDs.
237
+ UploadInitResult with publish_id and upload_url.
136
238
 
137
239
  Raises:
138
- FileNotFoundError: If the local file does not exist.
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
- if file_path.startswith(("http://", "https://")):
143
- logger.info(f"Downloading media from URL: {file_path}")
144
- file_path = await download_file(file_path)
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
- if not os.path.exists(file_path):
147
- raise FileNotFoundError(f"Media file not found: {file_path}")
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
- mime_type = detect_mime_type(file_path)
150
- file_size = os.path.getsize(file_path)
151
- self._validate_media(mime_type, file_size)
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
- # TikTok uses a one-step chunked upload
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
- # Note: TikTok's real API might have a different flow for processing.
160
- # This is a hypothetical implementation.
161
- if wait_for_processing:
162
- await self._wait_for_processing(result, file_path)
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
- return result
307
+ Returns:
308
+ UploadInitResult with publish_id (no upload_url for URL source).
165
309
 
166
- async def chunked_upload(
167
- self, file_path: str, *, chunk_size: int = DEFAULT_CHUNK_SIZE
168
- ) -> MediaUploadResult:
169
- """Upload a video using the chunked upload method.
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
- This is a simplified model of how a chunked upload to TikTok might work.
172
- The actual API may differ.
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
- chunk_size: Size of each upload chunk.
361
+ publish_id: The publish_id for progress tracking.
362
+ chunk_size: Size of each chunk (5MB - 64MB).
177
363
 
178
- Returns:
179
- MediaUploadResult with upload ID.
364
+ Raises:
365
+ MediaUploadError: If any chunk upload fails.
180
366
  """
181
367
  file_size = os.path.getsize(file_path)
182
- chunk_count = get_chunk_count(file_path, chunk_size)
183
- upload_id = None # Hypothetically obtained from an INIT step if needed
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
- upload_id=upload_id,
374
+ publish_id=publish_id,
189
375
  file_path=file_path,
190
376
  bytes_uploaded=0,
191
377
  total_bytes=file_size,
192
- status="init",
378
+ status="uploading",
193
379
  )
194
380
  )
195
381
 
196
- @retry_async(config=STANDARD_BACKOFF)
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
- if not upload_id:
233
- raise MediaUploadError(
234
- "Upload succeeded but did not return a video ID.", platform="tiktok"
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
- result = MediaUploadResult(
238
- upload_id=upload_id,
239
- media_id=upload_id, # Assume upload_id is the media_id
240
- size=file_size,
241
- )
405
+ response = await self._upload_client.put(
406
+ upload_url,
407
+ content=chunk_data,
408
+ headers=headers,
409
+ )
242
410
 
243
- if self.progress_callback:
244
- self.progress_callback(
245
- UploadProgress(
246
- upload_id=upload_id,
247
- file_path=file_path,
248
- bytes_uploaded=file_size,
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
- logger.info(f"Chunked upload completed for TikTok: {upload_id}")
255
- return result
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
- try:
258
- return await _do_upload()
259
- except httpx.HTTPError as e:
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 _wait_for_processing(
267
- self, result: MediaUploadResult, file_path: str
268
- ) -> None:
269
- """Wait for async video processing to complete.
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
- This is a hypothetical implementation.
446
+ Raises:
447
+ PlatformError: If the status check fails.
272
448
  """
273
- logger.info(f"Waiting for TikTok video processing: {result.upload_id}")
274
- # In a real implementation, you would poll a status endpoint.
275
- # Here, we'll just simulate a delay.
276
- await asyncio.sleep(5) # Simulate processing time
277
- if self.progress_callback:
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
- upload_id=result.upload_id,
497
+ publish_id=publish_id,
281
498
  file_path=file_path,
282
- bytes_uploaded=os.path.getsize(file_path),
283
- total_bytes=os.path.getsize(file_path),
499
+ bytes_uploaded=file_size,
500
+ total_bytes=file_size,
284
501
  status="processing",
285
502
  )
286
503
  )
287
- logger.info(f"TikTok video processing assumed complete: {result.upload_id}")
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 are: {', '.join(SUPPORTED_VIDEO_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
- # Add duration validation logic here if possible to check before upload
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
+ )