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.
Files changed (44) hide show
  1. marqetive/__init__.py +13 -13
  2. marqetive/core/__init__.py +1 -1
  3. marqetive/core/account_factory.py +2 -2
  4. marqetive/core/base_manager.py +4 -4
  5. marqetive/core/client.py +1 -1
  6. marqetive/core/registry.py +3 -3
  7. marqetive/platforms/__init__.py +6 -6
  8. marqetive/platforms/base.py +3 -3
  9. marqetive/platforms/exceptions.py +2 -1
  10. marqetive/platforms/instagram/__init__.py +3 -3
  11. marqetive/platforms/instagram/client.py +4 -4
  12. marqetive/platforms/instagram/exceptions.py +1 -1
  13. marqetive/platforms/instagram/factory.py +5 -5
  14. marqetive/platforms/instagram/manager.py +4 -4
  15. marqetive/platforms/instagram/media.py +2 -2
  16. marqetive/platforms/linkedin/__init__.py +3 -3
  17. marqetive/platforms/linkedin/client.py +4 -4
  18. marqetive/platforms/linkedin/exceptions.py +1 -1
  19. marqetive/platforms/linkedin/factory.py +5 -5
  20. marqetive/platforms/linkedin/manager.py +4 -4
  21. marqetive/platforms/linkedin/media.py +4 -4
  22. marqetive/platforms/models.py +2 -0
  23. marqetive/platforms/tiktok/__init__.py +7 -0
  24. marqetive/platforms/tiktok/client.py +492 -0
  25. marqetive/platforms/tiktok/exceptions.py +284 -0
  26. marqetive/platforms/tiktok/factory.py +188 -0
  27. marqetive/platforms/tiktok/manager.py +115 -0
  28. marqetive/platforms/tiktok/media.py +693 -0
  29. marqetive/platforms/twitter/__init__.py +3 -3
  30. marqetive/platforms/twitter/client.py +8 -54
  31. marqetive/platforms/twitter/exceptions.py +1 -1
  32. marqetive/platforms/twitter/factory.py +5 -6
  33. marqetive/platforms/twitter/manager.py +4 -4
  34. marqetive/platforms/twitter/media.py +4 -4
  35. marqetive/registry_init.py +10 -8
  36. marqetive/utils/__init__.py +3 -3
  37. marqetive/utils/file_handlers.py +1 -1
  38. marqetive/utils/oauth.py +137 -2
  39. marqetive/utils/token_validator.py +1 -1
  40. {marqetive_lib-0.1.1.dist-info → marqetive_lib-0.1.3.dist-info}/METADATA +1 -2
  41. marqetive_lib-0.1.3.dist-info/RECORD +47 -0
  42. marqetive/platforms/twitter/threads.py +0 -442
  43. marqetive_lib-0.1.1.dist-info/RECORD +0 -43
  44. {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 src.marqetive.platforms.twitter.client import TwitterClient
4
- from src.marqetive.platforms.twitter.factory import TwitterAccountFactory
5
- from src.marqetive.platforms.twitter.manager import TwitterPostManager
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"]