jebin2 commited on
Commit
bec2f86
·
1 Parent(s): 97dafd9

feat: Add TikTok OAuth auth and video publishing

Browse files

- Add tiktok/ module with auth.py and publisher.py
- Implement TikTok Login Kit OAuth 2.0 flow
- Use Content Posting API for video uploads (init → upload → check status)
- Register TikTok in factory.py
- Update app.py callback for tiktok_ state prefix
- Add tiktok_client_key and tiktok_client_secret to config.py
- Update frontend: TikTok status 'Active'
- Remove old tiktok_publisher.py skeleton

social_media_publishers/app.py CHANGED
@@ -76,6 +76,8 @@ def oauth2callback():
76
  platform = 'instagram'
77
  elif state.startswith('facebook_'):
78
  platform = 'facebook'
 
 
79
  else:
80
  platform = 'youtube' # Default for backward compatibility
81
 
 
76
  platform = 'instagram'
77
  elif state.startswith('facebook_'):
78
  platform = 'facebook'
79
+ elif state.startswith('tiktok_'):
80
+ platform = 'tiktok'
81
  else:
82
  platform = 'youtube' # Default for backward compatibility
83
 
social_media_publishers/factory.py CHANGED
@@ -1,6 +1,7 @@
1
  from .youtube.auth import YoutubeAuthCreator
2
  from .instagram.auth import InstagramAuthCreator
3
  from .facebook.auth import FacebookAuthCreator
 
4
 
5
  class PublisherFactory:
6
  """Factory to create social media auth creators and publishers."""
@@ -14,6 +15,8 @@ class PublisherFactory:
14
  return InstagramAuthCreator()
15
  if platform_lower == 'facebook':
16
  return FacebookAuthCreator()
 
 
17
  raise ValueError(f"Unknown platform: {platform}")
18
 
19
  @staticmethod
@@ -28,5 +31,9 @@ class PublisherFactory:
28
  if platform_lower == 'facebook':
29
  from .facebook.publisher import FacebookPublisher
30
  return FacebookPublisher()
 
 
 
31
  raise ValueError(f"Unknown platform: {platform}")
32
 
 
 
1
  from .youtube.auth import YoutubeAuthCreator
2
  from .instagram.auth import InstagramAuthCreator
3
  from .facebook.auth import FacebookAuthCreator
4
+ from .tiktok.auth import TikTokAuthCreator
5
 
6
  class PublisherFactory:
7
  """Factory to create social media auth creators and publishers."""
 
15
  return InstagramAuthCreator()
16
  if platform_lower == 'facebook':
17
  return FacebookAuthCreator()
18
+ if platform_lower == 'tiktok':
19
+ return TikTokAuthCreator()
20
  raise ValueError(f"Unknown platform: {platform}")
21
 
22
  @staticmethod
 
31
  if platform_lower == 'facebook':
32
  from .facebook.publisher import FacebookPublisher
33
  return FacebookPublisher()
34
+ if platform_lower == 'tiktok':
35
+ from .tiktok.publisher import TikTokPublisher
36
+ return TikTokPublisher()
37
  raise ValueError(f"Unknown platform: {platform}")
38
 
39
+
social_media_publishers/frontend/src/pages/Dashboard.jsx CHANGED
@@ -19,7 +19,7 @@ const platforms = [
19
  icon: Music2,
20
  color: 'text-teal-400',
21
  bg: 'bg-teal-400/10',
22
- status: 'Coming Soon'
23
  },
24
  {
25
  id: 'instagram',
 
19
  icon: Music2,
20
  color: 'text-teal-400',
21
  bg: 'bg-teal-400/10',
22
+ status: 'Active'
23
  },
24
  {
25
  id: 'instagram',
social_media_publishers/tiktok/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ # TikTok auth and publisher module
social_media_publishers/tiktok/auth.py ADDED
@@ -0,0 +1,166 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ TikTok OAuth Authentication Handler
3
+ Uses TikTok Login Kit (OAuth 2.0) for Content Posting API access
4
+ """
5
+
6
+ import os
7
+ import json
8
+ import requests
9
+ from typing import Dict, Any
10
+ from ..base import SocialAuthCreator
11
+ from src.config import get_config_value, load_configuration
12
+
13
+ # Initialize Config
14
+ load_configuration()
15
+
16
+
17
+ class TikTokAuthCreator(SocialAuthCreator):
18
+ """
19
+ Handles TikTok OAuth 2.0 Flow via TikTok Login Kit.
20
+
21
+ Required Environment Variables:
22
+ TIKTOK_CLIENT_KEY: TikTok App Client Key
23
+ TIKTOK_CLIENT_SECRET: TikTok App Client Secret
24
+ """
25
+
26
+ # TikTok API Scopes for Content Posting
27
+ SCOPES = [
28
+ 'user.info.basic',
29
+ 'video.publish',
30
+ 'video.upload'
31
+ ]
32
+
33
+ # TikTok OAuth endpoints
34
+ AUTH_URL = "https://www.tiktok.com/v2/auth/authorize/"
35
+ TOKEN_URL = "https://open.tiktokapis.com/v2/oauth/token/"
36
+ USER_INFO_URL = "https://open.tiktokapis.com/v2/user/info/"
37
+
38
+ def __init__(self):
39
+ super().__init__(platform_name='tiktok')
40
+ self.client_key = get_config_value('tiktok_client_key')
41
+ self.client_secret = get_config_value('tiktok_client_secret')
42
+
43
+ def _get_client_config(self) -> Dict[str, Any]:
44
+ """Get client configuration from env vars."""
45
+ if self.client_key and self.client_secret:
46
+ return {
47
+ "client_key": self.client_key,
48
+ "client_secret": self.client_secret
49
+ }
50
+ return None
51
+
52
+ def get_auth_url(self, redirect_uri: str) -> tuple[str, str]:
53
+ """Generate TikTok OAuth authorization URL."""
54
+ config = self._get_client_config()
55
+ if not config:
56
+ raise ValueError("TikTok Client Key and Secret not configured. Set TIKTOK_CLIENT_KEY and TIKTOK_CLIENT_SECRET.")
57
+
58
+ import secrets
59
+ state = secrets.token_urlsafe(32)
60
+
61
+ # TikTok requires CSRF state and code_verifier for PKCE
62
+ code_verifier = secrets.token_urlsafe(64)[:64]
63
+
64
+ params = {
65
+ 'client_key': config['client_key'],
66
+ 'redirect_uri': redirect_uri,
67
+ 'scope': ','.join(self.SCOPES),
68
+ 'response_type': 'code',
69
+ 'state': f"tiktok_{state}"
70
+ }
71
+
72
+ auth_url = f"{self.AUTH_URL}?" + "&".join(f"{k}={v}" for k, v in params.items())
73
+
74
+ # Store code_verifier in state for later use (would need session storage in practice)
75
+ return auth_url, f"tiktok_{state}"
76
+
77
+ def handle_callback(self, request_url: str, state: str, redirect_uri: str) -> Dict[str, Any]:
78
+ """Exchange authorization code for access token and save credentials."""
79
+ try:
80
+ config = self._get_client_config()
81
+ if not config:
82
+ return {"success": False, "error": "TikTok not configured"}
83
+
84
+ # Extract code from URL
85
+ from urllib.parse import urlparse, parse_qs
86
+ parsed = urlparse(request_url)
87
+ params = parse_qs(parsed.query)
88
+ code = params.get('code', [None])[0]
89
+
90
+ if not code:
91
+ error = params.get('error_description', params.get('error', ['Unknown error']))[0]
92
+ return {"success": False, "error": error}
93
+
94
+ # Exchange code for access token
95
+ token_data = {
96
+ 'client_key': config['client_key'],
97
+ 'client_secret': config['client_secret'],
98
+ 'code': code,
99
+ 'grant_type': 'authorization_code',
100
+ 'redirect_uri': redirect_uri
101
+ }
102
+
103
+ headers = {'Content-Type': 'application/x-www-form-urlencoded'}
104
+ response = requests.post(self.TOKEN_URL, data=token_data, headers=headers)
105
+
106
+ if response.status_code != 200:
107
+ return {"success": False, "error": f"Token exchange failed: {response.text}"}
108
+
109
+ result = response.json()
110
+
111
+ if 'error' in result:
112
+ return {"success": False, "error": result.get('error_description', result.get('error'))}
113
+
114
+ access_token = result.get('access_token')
115
+ refresh_token = result.get('refresh_token')
116
+ open_id = result.get('open_id')
117
+ expires_in = result.get('expires_in')
118
+
119
+ if not access_token:
120
+ return {"success": False, "error": "No access token returned"}
121
+
122
+ # Get user info for display name
123
+ username = "unknown"
124
+ try:
125
+ user_response = requests.get(
126
+ self.USER_INFO_URL,
127
+ headers={'Authorization': f'Bearer {access_token}'},
128
+ params={'fields': 'open_id,union_id,avatar_url,display_name'}
129
+ )
130
+ if user_response.status_code == 200:
131
+ user_data = user_response.json()
132
+ if 'data' in user_data and 'user' in user_data['data']:
133
+ username = user_data['data']['user'].get('display_name', open_id[:10])
134
+ except Exception as e:
135
+ print(f"⚠️ Could not fetch user info: {e}")
136
+ username = open_id[:10] if open_id else "unknown"
137
+
138
+ # Create safe filename
139
+ safe_name = "".join(c for c in username if c.isalnum() or c in ('-', '_')).strip()[:30]
140
+ if not safe_name:
141
+ safe_name = open_id[:20] if open_id else "default"
142
+ filename = f"tiktok_token_{safe_name}.json"
143
+
144
+ token_to_save = {
145
+ 'access_token': access_token,
146
+ 'refresh_token': refresh_token,
147
+ 'open_id': open_id,
148
+ 'expires_in': expires_in,
149
+ 'username': username
150
+ }
151
+
152
+ # Save using Base class method
153
+ self.save_token_data(token_to_save, filename)
154
+
155
+ print(f"✅ TikTok account connected: @{username}")
156
+
157
+ return {
158
+ "success": True,
159
+ "channel_name": f"@{username}",
160
+ "filename": filename
161
+ }
162
+
163
+ except Exception as e:
164
+ import traceback
165
+ traceback.print_exc()
166
+ return {"success": False, "error": str(e)}
social_media_publishers/{tiktok_publisher.py → tiktok/publisher.py} RENAMED
@@ -1,105 +1,179 @@
1
  """
2
- TikTok Video Scheduler for GitHub Actions
3
- Uses environment variables for authentication + APIClients for GCS download
4
  """
5
 
6
- import asyncio
7
- import requests
8
- import json
9
  import os
10
  import sys
11
- import base64
12
  import time
13
- import pandas as pd
14
- from datetime import datetime
 
 
 
15
 
16
- # Add parent directory to path to allow importing modules from src
17
- sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))
18
 
19
- from dotenv import load_dotenv
20
- from pathlib import Path
21
- from asset_manager.content_strategy_lib import get_content_strategy_lib
22
- from config import get_config_value
23
- import hashlib
24
- from google_src.gcs_utils import find_and_download_gcs_file
25
 
26
- DATA_DIR = Path("data")
27
 
28
- class TikTokPublisher:
 
 
 
 
 
 
 
 
29
  def __init__(self):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
30
  """
31
- Initialize TikTok scheduler using environment variables
32
 
33
- Required Environment Variables:
34
- TIKTOK_ACCESS_TOKEN: TikTok API access token
35
  """
36
- load_dotenv()
37
- self.access_token = get_config_value('tiktok_access_token')
38
- self.base_url = "https://open.tiktokapis.com/v2"
39
 
40
- if not self.access_token:
41
- raise ValueError("❌ Missing required environment variable: TIKTOK_ACCESS_TOKEN must be set")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
42
 
43
- def upload_video(self, video_path, title, privacy_level=None,
44
- disable_duet=False, disable_comment=False, disable_stitch=False):
45
  """
46
- Upload a video to TikTok
47
 
48
  Args:
49
- video_path: Local path to video file or base64 encoded string
50
- title: Video title/caption (max 2200 chars)
51
- privacy_level: PUBLIC_TO_EVERYONE, MUTUAL_FOLLOW_FRIENDS, SELF_ONLY
52
- disable_duet: Disable duet (default False)
53
- disable_comment: Disable comments (default False)
54
- disable_stitch: Disable stitch (default False)
55
 
56
  Returns:
57
- dict: Response with publish_id or error
58
  """
59
- if privacy_level is None:
60
- privacy_level = get_config_value('tiktok_privacy_level', 'PUBLIC_TO_EVERYONE')
61
-
62
- if not os.path.exists(video_path):
63
- return {'error': f'Video file not found: {video_path}'}
 
 
 
64
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
65
  video_size = os.path.getsize(video_path)
66
- print(f"🎬 Initializing upload for: {title[:50]} ({video_size / 1024:.2f} KB)")
67
 
68
- # Step 1: Initialize
69
- init_response = self._initialize_upload(video_size, title, privacy_level,
70
- disable_duet, disable_comment, disable_stitch)
71
- if 'error' in init_response:
72
- print(f"❌ Initialization failed: {init_response['error']}")
73
- return init_response
74
 
75
- publish_id = init_response['publish_id']
76
- upload_url = init_response['upload_url']
77
- print(f"✅ Upload initialized → Publish ID: {publish_id}")
78
 
79
- # Step 2: Upload file
80
  upload_success = self._upload_file(upload_url, video_path)
81
  if not upload_success:
82
- print(" Video upload failed")
83
- return {'error': 'Video upload failed'}
 
84
 
85
- print("✅ Video uploaded successfully, finalizing...")
86
  # Step 3: Check publish status
87
  return self._check_publish_status(publish_id)
88
-
89
- def _initialize_upload(self, video_size, title, privacy_level,
90
- disable_duet, disable_comment, disable_stitch):
91
- """Initialize the upload process"""
92
- endpoint = f"{self.base_url}/post/publish/video/init/"
93
  headers = {
94
  'Authorization': f'Bearer {self.access_token}',
95
  'Content-Type': 'application/json; charset=UTF-8'
96
  }
97
- chunk_size = min(video_size, 64 * 1024 * 1024)
 
98
  total_chunks = max(1, (video_size + chunk_size - 1) // chunk_size)
99
 
100
  data = {
101
  "post_info": {
102
- "title": title,
103
  "privacy_level": privacy_level,
104
  "disable_duet": disable_duet,
105
  "disable_comment": disable_comment,
@@ -115,35 +189,44 @@ class TikTokPublisher:
115
 
116
  response = requests.post(endpoint, headers=headers, json=data)
117
  result = response.json()
 
118
  if response.status_code == 200 and 'data' in result:
119
  return {
120
  'publish_id': result['data']['publish_id'],
121
  'upload_url': result['data']['upload_url']
122
  }
 
123
  error_msg = result.get('error', {}).get('message', 'Initialization failed')
124
  return {'error': error_msg, 'full_response': result}
125
-
126
- def _upload_file(self, upload_url, video_path):
127
- """Upload the video file to TikTok's upload URL"""
128
  try:
129
  with open(video_path, 'rb') as f:
130
  video_data = f.read()
 
 
131
  headers = {
132
  'Content-Type': 'video/mp4',
133
- 'Content-Length': str(len(video_data))
 
134
  }
 
135
  response = requests.put(upload_url, headers=headers, data=video_data)
136
- if response.status_code in [200, 204]:
 
137
  return True
138
- print(f"⚠️ Upload failed: {response.text}")
 
139
  return False
 
140
  except Exception as e:
141
  print(f"❌ Upload exception: {e}")
142
  return False
143
-
144
- def _check_publish_status(self, publish_id, max_attempts=15):
145
- """Poll TikTok API to check upload/publish status"""
146
- endpoint = f"{self.base_url}/post/publish/status/fetch/"
147
  headers = {
148
  'Authorization': f'Bearer {self.access_token}',
149
  'Content-Type': 'application/json; charset=UTF-8'
@@ -151,6 +234,7 @@ class TikTokPublisher:
151
  data = {"publish_id": publish_id}
152
 
153
  print("⏳ Checking publish status...")
 
154
  for attempt in range(1, max_attempts + 1):
155
  response = requests.post(endpoint, headers=headers, json=data)
156
  result = response.json()
@@ -158,11 +242,13 @@ class TikTokPublisher:
158
  if response.status_code == 200 and 'data' in result:
159
  status = result['data'].get('status')
160
  print(f" Attempt {attempt}/{max_attempts}: {status}")
 
161
  if status == 'PUBLISH_COMPLETE':
162
  print("✅ Video published successfully!")
163
  return {
164
  'success': True,
165
  'publish_id': publish_id,
 
166
  'status': status
167
  }
168
  elif status == 'FAILED':
@@ -173,108 +259,5 @@ class TikTokPublisher:
173
  print(f"⚠️ Status check error: {error_msg}")
174
 
175
  time.sleep(10)
176
- return {'error': 'Publishing timeout - still processing'}
177
-
178
- # ===========================================================
179
- # MAIN (async) — CSV-driven + GCS download like YouTubePublisher
180
- # ===========================================================
181
- async def main():
182
- try:
183
- scheduler = TikTokPublisher()
184
 
185
- content_lib = get_content_strategy_lib()
186
- df = content_lib.strategies
187
-
188
- all_rows = []
189
- worksheet_name = get_config_value("content_strategy_worksheet", "TikTok_Upload")
190
- for i, row in df.iterrows():
191
- all_rows.append((worksheet_name, row.to_dict()))
192
-
193
- print(f"📈 Found {len(all_rows)} TikTok videos to upload.")
194
-
195
- for idx, (csv_name, row) in enumerate(all_rows):
196
- tts_script = row.get("TTS Script (AI Avatar)", "").strip()
197
- title = row.get("Captions", "").strip()
198
-
199
- print("\n" + "="*50)
200
- print(f"🎞️ Uploading TikTok {idx + 1}/{len(all_rows)}")
201
- print(f"🎵 Title: {title[:80]}")
202
- print("="*50)
203
-
204
- # Download from GCS
205
- local_path = find_and_download_gcs_file(tts_script)
206
- if not local_path or not os.path.exists(local_path):
207
- print(f"❌ File not found, skipping: {tts_script}")
208
- continue
209
-
210
- result = scheduler.upload_video(video_path=local_path, title=title)
211
- print("\nRESULT:")
212
- print(json.dumps(result, indent=2))
213
- print("="*50)
214
-
215
- if not result.get('success'):
216
- sys.exit(1)
217
-
218
- print("✅ All TikTok uploads complete!")
219
- sys.exit(0)
220
-
221
- except Exception as e:
222
- print(f"❌ Fatal error: {e}")
223
- import traceback
224
- traceback.print_exc()
225
- sys.exit(1)
226
-
227
- if __name__ == "__main__":
228
- asyncio.run(main())
229
-
230
- """
231
- GitHub Actions Workflow Example:
232
- ================================
233
-
234
- name: Post to TikTok
235
- on:
236
- schedule:
237
- - cron: '0 14 * * *' # Daily at 2 PM UTC
238
- workflow_dispatch:
239
- inputs:
240
- video_path:
241
- description: 'Video file path'
242
- required: true
243
- title:
244
- description: 'Video title'
245
- required: true
246
-
247
- jobs:
248
- post:
249
- runs-on: ubuntu-latest
250
- steps:
251
- - uses: actions/checkout@v3
252
-
253
- - name: Set up Python
254
- uses: actions/setup-python@v4
255
- with:
256
- python-version: '3.10'
257
-
258
- - name: Install dependencies
259
- run: pip install requests
260
-
261
- - name: Post to TikTok
262
- env:
263
- TIKTOK_ACCESS_TOKEN: ${{ secrets.TIKTOK_ACCESS_TOKEN }}
264
- VIDEO_PATH: ${{ github.event.inputs.video_path || 'videos/my-video.mp4' }}
265
- VIDEO_TITLE: ${{ github.event.inputs.title || 'Check this out! #fyp #viral' }}
266
- TIKTOK_PRIVACY_LEVEL: 'PUBLIC_TO_EVERYONE'
267
- DISABLE_DUET: 'false'
268
- DISABLE_COMMENT: 'false'
269
- DISABLE_STITCH: 'false'
270
- run: python tiktok_scheduler.py
271
-
272
- Required GitHub Secrets:
273
- - TIKTOK_ACCESS_TOKEN
274
-
275
- Optional Environment Variables:
276
- - TIKTOK_PRIVACY_LEVEL (default: PUBLIC_TO_EVERYONE)
277
- - DISABLE_DUET (default: false)
278
- - DISABLE_COMMENT (default: false)
279
- - DISABLE_STITCH (default: false)
280
- """
 
1
  """
2
+ TikTok Video Publisher
3
+ Uses TikTok Content Posting API for publishing videos
4
  """
5
 
 
 
 
6
  import os
7
  import sys
8
+ import json
9
  import time
10
+ import requests
11
+ from typing import Dict, Any, Optional
12
+
13
+ # Add parent directory to path
14
+ sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..')))
15
 
16
+ from social_media_publishers.base import SocialPublisher
17
+ from src.config import get_config_value, load_configuration
18
 
19
+ # Initialize Config
20
+ load_configuration()
 
 
 
 
21
 
 
22
 
23
+ class TikTokPublisher(SocialPublisher):
24
+ """
25
+ Publishes videos to TikTok via Content Posting API.
26
+
27
+ Uses TokenManager for encrypted credential storage.
28
+ """
29
+
30
+ BASE_URL = "https://open.tiktokapis.com/v2"
31
+
32
  def __init__(self):
33
+ """Initialize TikTok publisher."""
34
+ self.access_token = None
35
+ self.open_id = None
36
+ self.username = None
37
+ self.token_manager = None
38
+
39
+ # Try to initialize TokenManager
40
+ encryption_key = get_config_value("encryption_key")
41
+ hf_token = get_config_value("hf_token")
42
+
43
+ if encryption_key and hf_token:
44
+ try:
45
+ from youtube_auto_pub.token_manager import TokenManager
46
+ from youtube_auto_pub.config import YouTubeConfig
47
+
48
+ config = YouTubeConfig(
49
+ encryption_key=encryption_key,
50
+ hf_token=hf_token,
51
+ hf_repo_id="ai-workbench/data_process"
52
+ )
53
+ self.token_manager = TokenManager(config)
54
+ except Exception as e:
55
+ print(f"⚠️ Could not initialize TokenManager: {e}")
56
+
57
+ # Note: Authentication happens when publish() is called via app.py
58
+ # The account is passed at runtime, not from env config
59
+
60
+ def authenticate(self, account_id: Optional[str] = None) -> None:
61
  """
62
+ Authenticate with TikTok using stored credentials.
63
 
64
+ Args:
65
+ account_id: TikTok username/display name for TokenManager lookup
66
  """
67
+ if not self.token_manager:
68
+ raise ValueError("TokenManager not initialized (check ENCRYPTION_KEY/HF_TOKEN)")
 
69
 
70
+ # account_id is required - passed from UI/API, not from env
71
+
72
+ if not account_id:
73
+ raise ValueError("account_id (TikTok username) is required for authentication")
74
+
75
+ try:
76
+ print(f"🔄 Loading credentials for TikTok: @{account_id}")
77
+
78
+ # Construct filename
79
+ safe_name = "".join(c for c in account_id if c.isalnum() or c in ('-', '_')).strip()[:30]
80
+ filename = f"tiktok_token_{safe_name}.json"
81
+
82
+ # Download and decrypt
83
+ file_path = self.token_manager.download_and_decrypt(filename)
84
+
85
+ if not os.path.exists(file_path):
86
+ raise FileNotFoundError(f"Token file not found: {file_path}")
87
+
88
+ with open(file_path, 'r') as f:
89
+ token_data = json.load(f)
90
+
91
+ self.access_token = token_data.get('access_token')
92
+ self.open_id = token_data.get('open_id')
93
+ self.username = token_data.get('username')
94
+
95
+ if not self.access_token:
96
+ raise ValueError("Invalid token data - missing access_token")
97
+
98
+ print(f"✅ Authenticated as @{self.username}")
99
+
100
+ except Exception as e:
101
+ print(f"❌ Authentication failed: {e}")
102
+ raise
103
 
104
+ def publish(self, content_path: str, metadata: Dict[str, Any]) -> Dict[str, Any]:
 
105
  """
106
+ Publish a video to TikTok.
107
 
108
  Args:
109
+ content_path: Path to video file (or GCS path)
110
+ metadata: Dict with 'title', 'privacy_level', etc.
 
 
 
 
111
 
112
  Returns:
113
+ Dict with success status and publish_id or error
114
  """
115
+ if not self.access_token:
116
+ raise ValueError("Not authenticated. Call authenticate() first.")
117
+
118
+ # Prepare content (handles GCS download if needed)
119
+ local_path = self.prepare_content(content_path)
120
+
121
+ if not os.path.exists(local_path):
122
+ return {"error": f"Video file not found: {local_path}"}
123
 
124
+ title = metadata.get('title', metadata.get('description', metadata.get('caption', '')))
125
+ privacy_level = metadata.get('privacy_level', 'PUBLIC_TO_EVERYONE')
126
+ disable_duet = metadata.get('disable_duet', False)
127
+ disable_comment = metadata.get('disable_comment', False)
128
+ disable_stitch = metadata.get('disable_stitch', False)
129
+
130
+ return self._upload_video(local_path, title, privacy_level,
131
+ disable_duet, disable_comment, disable_stitch)
132
+
133
+ def _upload_video(self, video_path: str, title: str, privacy_level: str,
134
+ disable_duet: bool, disable_comment: bool, disable_stitch: bool) -> Dict[str, Any]:
135
+ """
136
+ Upload video to TikTok using the Content Posting API.
137
+ 3-step process: Initialize → Upload → Check Status
138
+ """
139
  video_size = os.path.getsize(video_path)
140
+ print(f"🎬 Uploading TikTok video: {title[:50]}... ({video_size / 1024 / 1024:.1f} MB)")
141
 
142
+ # Step 1: Initialize upload
143
+ init_result = self._initialize_upload(video_size, title, privacy_level,
144
+ disable_duet, disable_comment, disable_stitch)
145
+ if 'error' in init_result:
146
+ return init_result
 
147
 
148
+ publish_id = init_result['publish_id']
149
+ upload_url = init_result['upload_url']
150
+ print(f"✅ Upload initialized: {publish_id}")
151
 
152
+ # Step 2: Upload video file
153
  upload_success = self._upload_file(upload_url, video_path)
154
  if not upload_success:
155
+ return {"error": "Video upload failed"}
156
+
157
+ print("✅ Video uploaded, checking status...")
158
 
 
159
  # Step 3: Check publish status
160
  return self._check_publish_status(publish_id)
161
+
162
+ def _initialize_upload(self, video_size: int, title: str, privacy_level: str,
163
+ disable_duet: bool, disable_comment: bool, disable_stitch: bool) -> Dict[str, Any]:
164
+ """Initialize the upload process."""
165
+ endpoint = f"{self.BASE_URL}/post/publish/video/init/"
166
  headers = {
167
  'Authorization': f'Bearer {self.access_token}',
168
  'Content-Type': 'application/json; charset=UTF-8'
169
  }
170
+
171
+ chunk_size = min(video_size, 64 * 1024 * 1024) # Max 64MB chunks
172
  total_chunks = max(1, (video_size + chunk_size - 1) // chunk_size)
173
 
174
  data = {
175
  "post_info": {
176
+ "title": title[:2200], # TikTok limit
177
  "privacy_level": privacy_level,
178
  "disable_duet": disable_duet,
179
  "disable_comment": disable_comment,
 
189
 
190
  response = requests.post(endpoint, headers=headers, json=data)
191
  result = response.json()
192
+
193
  if response.status_code == 200 and 'data' in result:
194
  return {
195
  'publish_id': result['data']['publish_id'],
196
  'upload_url': result['data']['upload_url']
197
  }
198
+
199
  error_msg = result.get('error', {}).get('message', 'Initialization failed')
200
  return {'error': error_msg, 'full_response': result}
201
+
202
+ def _upload_file(self, upload_url: str, video_path: str) -> bool:
203
+ """Upload the video file to TikTok's upload URL."""
204
  try:
205
  with open(video_path, 'rb') as f:
206
  video_data = f.read()
207
+
208
+ file_size = len(video_data)
209
  headers = {
210
  'Content-Type': 'video/mp4',
211
+ 'Content-Length': str(file_size),
212
+ 'Content-Range': f'bytes 0-{file_size-1}/{file_size}'
213
  }
214
+
215
  response = requests.put(upload_url, headers=headers, data=video_data)
216
+
217
+ if response.status_code in [200, 201, 204]:
218
  return True
219
+
220
+ print(f"⚠️ Upload failed ({response.status_code}): {response.text}")
221
  return False
222
+
223
  except Exception as e:
224
  print(f"❌ Upload exception: {e}")
225
  return False
226
+
227
+ def _check_publish_status(self, publish_id: str, max_attempts: int = 20) -> Dict[str, Any]:
228
+ """Poll TikTok API to check upload/publish status."""
229
+ endpoint = f"{self.BASE_URL}/post/publish/status/fetch/"
230
  headers = {
231
  'Authorization': f'Bearer {self.access_token}',
232
  'Content-Type': 'application/json; charset=UTF-8'
 
234
  data = {"publish_id": publish_id}
235
 
236
  print("⏳ Checking publish status...")
237
+
238
  for attempt in range(1, max_attempts + 1):
239
  response = requests.post(endpoint, headers=headers, json=data)
240
  result = response.json()
 
242
  if response.status_code == 200 and 'data' in result:
243
  status = result['data'].get('status')
244
  print(f" Attempt {attempt}/{max_attempts}: {status}")
245
+
246
  if status == 'PUBLISH_COMPLETE':
247
  print("✅ Video published successfully!")
248
  return {
249
  'success': True,
250
  'publish_id': publish_id,
251
+ 'video_id': publish_id,
252
  'status': status
253
  }
254
  elif status == 'FAILED':
 
259
  print(f"⚠️ Status check error: {error_msg}")
260
 
261
  time.sleep(10)
 
 
 
 
 
 
 
 
262
 
263
+ return {'error': 'Publishing timeout - still processing', 'publish_id': publish_id}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src/config.py CHANGED
@@ -190,6 +190,8 @@ def load_configuration(force_reload: bool = False) -> Dict[str, Any]:
190
  # TikTok
191
  "tiktok_access_token": os.getenv("TIKTOK_ACCESS_TOKEN"),
192
  "tiktok_privacy_level": os.getenv("TIKTOK_PRIVACY_LEVEL", "PUBLIC_TO_EVERYONE"),
 
 
193
 
194
  # YouTube
195
  "youtube_refresh_token": os.getenv("YOUTUBE_REFRESH_TOKEN"),
 
190
  # TikTok
191
  "tiktok_access_token": os.getenv("TIKTOK_ACCESS_TOKEN"),
192
  "tiktok_privacy_level": os.getenv("TIKTOK_PRIVACY_LEVEL", "PUBLIC_TO_EVERYONE"),
193
+ "tiktok_client_key": os.getenv("TIKTOK_CLIENT_KEY"),
194
+ "tiktok_client_secret": os.getenv("TIKTOK_CLIENT_SECRET"),
195
 
196
  # YouTube
197
  "youtube_refresh_token": os.getenv("YOUTUBE_REFRESH_TOKEN"),