upload-post 0.1.2__py3-none-any.whl → 2.0.0__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.
upload_post/api_client.py CHANGED
@@ -1,6 +1,12 @@
1
- from pathlib import Path
2
- from typing import Dict, List, Union, Optional
1
+ """
2
+ Upload-Post API Client
3
+
4
+ Cross-platform social media upload for TikTok, Instagram, YouTube, LinkedIn,
5
+ Facebook, Pinterest, Threads, Reddit, Bluesky, and X (Twitter).
6
+ """
3
7
 
8
+ from pathlib import Path
9
+ from typing import Dict, List, Union, Optional, Any, Literal
4
10
  import requests
5
11
 
6
12
 
@@ -8,17 +14,328 @@ class UploadPostError(Exception):
8
14
  """Base exception for Upload-Post API errors"""
9
15
  pass
10
16
 
17
+
11
18
  class UploadPostClient:
19
+ """
20
+ Upload-Post API Client
21
+
22
+ Supports uploading to: TikTok, Instagram, YouTube, LinkedIn, Facebook,
23
+ Pinterest, Threads, Reddit, Bluesky, X (Twitter)
24
+
25
+ Example:
26
+ >>> client = UploadPostClient("YOUR_API_KEY")
27
+ >>> response = client.upload_video(
28
+ ... "video.mp4",
29
+ ... title="My awesome video",
30
+ ... user="my-profile",
31
+ ... platforms=["tiktok", "instagram"]
32
+ ... )
33
+ """
34
+
12
35
  BASE_URL = "https://api.upload-post.com/api"
13
36
 
14
37
  def __init__(self, api_key: str):
38
+ """
39
+ Initialize the Upload-Post client.
40
+
41
+ Args:
42
+ api_key: Your API key from Upload-Post
43
+ """
15
44
  self.api_key = api_key
16
45
  self.session = requests.Session()
17
46
  self.session.headers.update({
18
47
  "Authorization": f"Apikey {self.api_key}",
19
- "User-Agent": f"upload-post-python-client/0.1.0"
48
+ "User-Agent": "upload-post-python-client/2.0.0"
20
49
  })
21
50
 
51
+ def _request(
52
+ self,
53
+ endpoint: str,
54
+ method: str = "GET",
55
+ data: Optional[List[tuple]] = None,
56
+ files: Optional[List[tuple]] = None,
57
+ json_data: Optional[Dict] = None,
58
+ params: Optional[Dict] = None
59
+ ) -> Dict:
60
+ """Make an API request."""
61
+ url = f"{self.BASE_URL}{endpoint}"
62
+
63
+ try:
64
+ if method == "GET":
65
+ response = self.session.get(url, params=params)
66
+ elif method == "POST":
67
+ if json_data:
68
+ response = self.session.post(url, json=json_data)
69
+ else:
70
+ response = self.session.post(url, data=data, files=files)
71
+ elif method == "DELETE":
72
+ if json_data:
73
+ response = self.session.delete(url, json=json_data)
74
+ else:
75
+ response = self.session.delete(url)
76
+ else:
77
+ raise UploadPostError(f"Unsupported HTTP method: {method}")
78
+
79
+ response.raise_for_status()
80
+ return response.json()
81
+
82
+ except requests.exceptions.RequestException as e:
83
+ error_msg = str(e)
84
+ if hasattr(e, 'response') and e.response is not None:
85
+ try:
86
+ error_data = e.response.json()
87
+ error_msg = error_data.get('message') or error_data.get('detail') or str(error_data)
88
+ except:
89
+ pass
90
+ raise UploadPostError(f"API request failed: {error_msg}") from e
91
+
92
+ def _add_common_params(
93
+ self,
94
+ data: List[tuple],
95
+ user: str,
96
+ title: str,
97
+ platforms: List[str],
98
+ first_comment: Optional[str] = None,
99
+ alt_text: Optional[str] = None,
100
+ scheduled_date: Optional[str] = None,
101
+ timezone: Optional[str] = None,
102
+ add_to_queue: Optional[bool] = None,
103
+ async_upload: Optional[bool] = None,
104
+ **kwargs
105
+ ):
106
+ """Add common upload parameters."""
107
+ data.append(("user", user))
108
+ data.append(("title", title))
109
+ for p in platforms:
110
+ data.append(("platform[]", p))
111
+
112
+ if first_comment:
113
+ data.append(("first_comment", first_comment))
114
+ if alt_text:
115
+ data.append(("alt_text", alt_text))
116
+ if scheduled_date:
117
+ data.append(("scheduled_date", scheduled_date))
118
+ if timezone:
119
+ data.append(("timezone", timezone))
120
+ if add_to_queue is not None:
121
+ data.append(("add_to_queue", str(add_to_queue).lower()))
122
+ if async_upload is not None:
123
+ data.append(("async_upload", str(async_upload).lower()))
124
+
125
+ # Platform-specific title overrides
126
+ title_overrides = [
127
+ "bluesky_title", "instagram_title", "facebook_title", "tiktok_title",
128
+ "linkedin_title", "x_title", "youtube_title", "pinterest_title", "threads_title"
129
+ ]
130
+ for key in title_overrides:
131
+ if kwargs.get(key):
132
+ data.append((key, kwargs[key]))
133
+
134
+ # Platform-specific description overrides
135
+ desc_overrides = [
136
+ "description", "linkedin_description", "youtube_description",
137
+ "facebook_description", "tiktok_description", "pinterest_description"
138
+ ]
139
+ for key in desc_overrides:
140
+ if kwargs.get(key):
141
+ data.append((key, kwargs[key]))
142
+
143
+ # Platform-specific first comment overrides
144
+ comment_overrides = [
145
+ "instagram_first_comment", "facebook_first_comment", "x_first_comment",
146
+ "threads_first_comment", "youtube_first_comment", "reddit_first_comment",
147
+ "bluesky_first_comment"
148
+ ]
149
+ for key in comment_overrides:
150
+ if kwargs.get(key):
151
+ data.append((key, kwargs[key]))
152
+
153
+ def _add_tiktok_params(self, data: List[tuple], is_video: bool = True, **kwargs):
154
+ """Add TikTok-specific parameters."""
155
+ if kwargs.get("disable_comment") is not None:
156
+ data.append(("disable_comment", str(kwargs["disable_comment"]).lower()))
157
+ if kwargs.get("brand_content_toggle") is not None:
158
+ data.append(("brand_content_toggle", str(kwargs["brand_content_toggle"]).lower()))
159
+ if kwargs.get("brand_organic_toggle") is not None:
160
+ data.append(("brand_organic_toggle", str(kwargs["brand_organic_toggle"]).lower()))
161
+
162
+ if is_video:
163
+ if kwargs.get("privacy_level"):
164
+ data.append(("privacy_level", kwargs["privacy_level"]))
165
+ if kwargs.get("disable_duet") is not None:
166
+ data.append(("disable_duet", str(kwargs["disable_duet"]).lower()))
167
+ if kwargs.get("disable_stitch") is not None:
168
+ data.append(("disable_stitch", str(kwargs["disable_stitch"]).lower()))
169
+ if kwargs.get("cover_timestamp") is not None:
170
+ data.append(("cover_timestamp", str(kwargs["cover_timestamp"])))
171
+ if kwargs.get("is_aigc") is not None:
172
+ data.append(("is_aigc", str(kwargs["is_aigc"]).lower()))
173
+ if kwargs.get("post_mode"):
174
+ data.append(("post_mode", kwargs["post_mode"]))
175
+ else:
176
+ if kwargs.get("auto_add_music") is not None:
177
+ data.append(("auto_add_music", str(kwargs["auto_add_music"]).lower()))
178
+ if kwargs.get("photo_cover_index") is not None:
179
+ data.append(("photo_cover_index", str(kwargs["photo_cover_index"])))
180
+
181
+ def _add_instagram_params(self, data: List[tuple], is_video: bool = True, **kwargs):
182
+ """Add Instagram-specific parameters."""
183
+ if kwargs.get("media_type"):
184
+ data.append(("media_type", kwargs["media_type"]))
185
+ if kwargs.get("collaborators"):
186
+ data.append(("collaborators", kwargs["collaborators"]))
187
+ if kwargs.get("user_tags"):
188
+ data.append(("user_tags", kwargs["user_tags"]))
189
+ if kwargs.get("location_id"):
190
+ data.append(("location_id", kwargs["location_id"]))
191
+
192
+ if is_video:
193
+ if kwargs.get("share_to_feed") is not None:
194
+ data.append(("share_to_feed", str(kwargs["share_to_feed"]).lower()))
195
+ if kwargs.get("cover_url"):
196
+ data.append(("cover_url", kwargs["cover_url"]))
197
+ if kwargs.get("audio_name"):
198
+ data.append(("audio_name", kwargs["audio_name"]))
199
+ if kwargs.get("thumb_offset"):
200
+ data.append(("thumb_offset", kwargs["thumb_offset"]))
201
+
202
+ def _add_youtube_params(self, data: List[tuple], **kwargs):
203
+ """Add YouTube-specific parameters."""
204
+ if kwargs.get("tags"):
205
+ tags = kwargs["tags"]
206
+ if isinstance(tags, str):
207
+ tags = [t.strip() for t in tags.split(",")]
208
+ for tag in tags:
209
+ data.append(("tags[]", tag))
210
+ if kwargs.get("categoryId"):
211
+ data.append(("categoryId", kwargs["categoryId"]))
212
+ if kwargs.get("privacyStatus"):
213
+ data.append(("privacyStatus", kwargs["privacyStatus"]))
214
+ if kwargs.get("embeddable") is not None:
215
+ data.append(("embeddable", str(kwargs["embeddable"]).lower()))
216
+ if kwargs.get("license"):
217
+ data.append(("license", kwargs["license"]))
218
+ if kwargs.get("publicStatsViewable") is not None:
219
+ data.append(("publicStatsViewable", str(kwargs["publicStatsViewable"]).lower()))
220
+ if kwargs.get("thumbnail_url"):
221
+ data.append(("thumbnail_url", kwargs["thumbnail_url"]))
222
+ if kwargs.get("selfDeclaredMadeForKids") is not None:
223
+ data.append(("selfDeclaredMadeForKids", str(kwargs["selfDeclaredMadeForKids"]).lower()))
224
+ if kwargs.get("containsSyntheticMedia") is not None:
225
+ data.append(("containsSyntheticMedia", str(kwargs["containsSyntheticMedia"]).lower()))
226
+ if kwargs.get("defaultLanguage"):
227
+ data.append(("defaultLanguage", kwargs["defaultLanguage"]))
228
+ if kwargs.get("defaultAudioLanguage"):
229
+ data.append(("defaultAudioLanguage", kwargs["defaultAudioLanguage"]))
230
+ if kwargs.get("allowedCountries"):
231
+ data.append(("allowedCountries", kwargs["allowedCountries"]))
232
+ if kwargs.get("blockedCountries"):
233
+ data.append(("blockedCountries", kwargs["blockedCountries"]))
234
+ if kwargs.get("hasPaidProductPlacement") is not None:
235
+ data.append(("hasPaidProductPlacement", str(kwargs["hasPaidProductPlacement"]).lower()))
236
+ if kwargs.get("recordingDate"):
237
+ data.append(("recordingDate", kwargs["recordingDate"]))
238
+
239
+ def _add_linkedin_params(self, data: List[tuple], **kwargs):
240
+ """Add LinkedIn-specific parameters."""
241
+ if kwargs.get("visibility"):
242
+ data.append(("visibility", kwargs["visibility"]))
243
+ if kwargs.get("target_linkedin_page_id"):
244
+ data.append(("target_linkedin_page_id", kwargs["target_linkedin_page_id"]))
245
+
246
+ def _add_facebook_params(self, data: List[tuple], is_video: bool = False, is_text: bool = False, **kwargs):
247
+ """Add Facebook-specific parameters."""
248
+ if kwargs.get("facebook_page_id"):
249
+ data.append(("facebook_page_id", kwargs["facebook_page_id"]))
250
+
251
+ if is_video:
252
+ if kwargs.get("video_state"):
253
+ data.append(("video_state", kwargs["video_state"]))
254
+ if kwargs.get("facebook_media_type"):
255
+ data.append(("facebook_media_type", kwargs["facebook_media_type"]))
256
+
257
+ if is_text and kwargs.get("facebook_link_url"):
258
+ data.append(("facebook_link_url", kwargs["facebook_link_url"]))
259
+
260
+ def _add_pinterest_params(self, data: List[tuple], is_video: bool = False, **kwargs):
261
+ """Add Pinterest-specific parameters."""
262
+ if kwargs.get("pinterest_board_id"):
263
+ data.append(("pinterest_board_id", kwargs["pinterest_board_id"]))
264
+ if kwargs.get("pinterest_alt_text"):
265
+ data.append(("pinterest_alt_text", kwargs["pinterest_alt_text"]))
266
+ if kwargs.get("pinterest_link"):
267
+ data.append(("pinterest_link", kwargs["pinterest_link"]))
268
+
269
+ if is_video:
270
+ if kwargs.get("pinterest_cover_image_url"):
271
+ data.append(("pinterest_cover_image_url", kwargs["pinterest_cover_image_url"]))
272
+ if kwargs.get("pinterest_cover_image_content_type"):
273
+ data.append(("pinterest_cover_image_content_type", kwargs["pinterest_cover_image_content_type"]))
274
+ if kwargs.get("pinterest_cover_image_data"):
275
+ data.append(("pinterest_cover_image_data", kwargs["pinterest_cover_image_data"]))
276
+ if kwargs.get("pinterest_cover_image_key_frame_time") is not None:
277
+ data.append(("pinterest_cover_image_key_frame_time", str(kwargs["pinterest_cover_image_key_frame_time"])))
278
+
279
+ def _add_x_params(self, data: List[tuple], is_text: bool = False, **kwargs):
280
+ """Add X (Twitter) specific parameters."""
281
+ reply_settings = kwargs.get("reply_settings")
282
+ if reply_settings and reply_settings != "everyone":
283
+ data.append(("reply_settings", reply_settings))
284
+ if kwargs.get("nullcast") is not None:
285
+ data.append(("nullcast", str(kwargs["nullcast"]).lower()))
286
+ if kwargs.get("quote_tweet_id"):
287
+ data.append(("quote_tweet_id", kwargs["quote_tweet_id"]))
288
+ if kwargs.get("geo_place_id"):
289
+ data.append(("geo_place_id", kwargs["geo_place_id"]))
290
+ if kwargs.get("for_super_followers_only") is not None:
291
+ data.append(("for_super_followers_only", str(kwargs["for_super_followers_only"]).lower()))
292
+ if kwargs.get("community_id"):
293
+ data.append(("community_id", kwargs["community_id"]))
294
+ if kwargs.get("share_with_followers") is not None:
295
+ data.append(("share_with_followers", str(kwargs["share_with_followers"]).lower()))
296
+ if kwargs.get("direct_message_deep_link"):
297
+ data.append(("direct_message_deep_link", kwargs["direct_message_deep_link"]))
298
+ if kwargs.get("x_long_text_as_post") is not None:
299
+ data.append(("x_long_text_as_post", str(kwargs["x_long_text_as_post"]).lower()))
300
+
301
+ if not is_text:
302
+ if kwargs.get("tagged_user_ids"):
303
+ ids = kwargs["tagged_user_ids"]
304
+ if isinstance(ids, str):
305
+ ids = [t.strip() for t in ids.split(",")]
306
+ for uid in ids:
307
+ data.append(("tagged_user_ids[]", uid))
308
+ if kwargs.get("place_id"):
309
+ data.append(("place_id", kwargs["place_id"]))
310
+ else:
311
+ if kwargs.get("post_url"):
312
+ data.append(("post_url", kwargs["post_url"]))
313
+ if kwargs.get("card_uri"):
314
+ data.append(("card_uri", kwargs["card_uri"]))
315
+
316
+ if kwargs.get("poll_options"):
317
+ poll_opts = kwargs["poll_options"]
318
+ if isinstance(poll_opts, str):
319
+ poll_opts = [o.strip() for o in poll_opts.split(",")]
320
+ for opt in poll_opts:
321
+ data.append(("poll_options[]", opt))
322
+ if kwargs.get("poll_duration"):
323
+ data.append(("poll_duration", str(kwargs["poll_duration"])))
324
+ if kwargs.get("poll_reply_settings"):
325
+ data.append(("poll_reply_settings", kwargs["poll_reply_settings"]))
326
+
327
+ def _add_threads_params(self, data: List[tuple], **kwargs):
328
+ """Add Threads-specific parameters."""
329
+ if kwargs.get("threads_long_text_as_post") is not None:
330
+ data.append(("threads_long_text_as_post", str(kwargs["threads_long_text_as_post"]).lower()))
331
+
332
+ def _add_reddit_params(self, data: List[tuple], **kwargs):
333
+ """Add Reddit-specific parameters."""
334
+ if kwargs.get("subreddit"):
335
+ data.append(("subreddit", kwargs["subreddit"]))
336
+ if kwargs.get("flair_id"):
337
+ data.append(("flair_id", kwargs["flair_id"]))
338
+
22
339
  def upload_video(
23
340
  self,
24
341
  video_path: Union[str, Path],
@@ -28,241 +345,601 @@ class UploadPostClient:
28
345
  **kwargs
29
346
  ) -> Dict:
30
347
  """
31
- Upload a video to specified social media platforms.
348
+ Upload a video to social media platforms.
32
349
 
33
350
  Args:
34
- video_path: Path to video file (str or Path) or video URL (str).
35
- title: Video title.
36
- user: User identifier.
37
- platforms: List of platforms (e.g., ["tiktok", "instagram", "linkedin"]).
38
- **kwargs: Platform-specific parameters.
39
- tiktok: privacy_level (str), disable_duet (bool), disable_comment (bool),
40
- disable_stitch (bool), cover_timestamp (int), brand_content_toggle (bool),
41
- brand_organic (bool), branded_content (bool), brand_organic_toggle (bool),
42
- is_aigc (bool)
43
- instagram: media_type (str), share_to_feed (bool), collaborators (str),
44
- cover_url (str), audio_name (str), user_tags (str),
45
- location_id (str), thumb_offset (str)
46
- linkedin: description (str), visibility (str), target_linkedin_page_id (str)
47
- youtube: description (str), tags (List[str]), categoryId (str),
48
- privacyStatus (str), embeddable (bool), license (str),
49
- publicStatsViewable (bool), madeForKids (bool)
50
- facebook: facebook_page_id (str), description (str), video_state (str)
51
- threads: description (str)
52
- x: tagged_user_ids (List[str]), reply_settings (str), nullcast (bool),
53
- place_id (str), poll_duration (int), poll_options (List[str]),
54
- poll_reply_settings (str)
55
- pinterest: pinterest_board_id (str), pinterest_link (str),
56
- pinterest_cover_image_url (str),
57
- pinterest_cover_image_content_type (str),
58
- pinterest_cover_image_data (str),
59
- pinterest_cover_image_key_frame_time (int)
351
+ video_path: Path to video file or video URL.
352
+ title: Video title/caption.
353
+ user: User identifier (profile name).
354
+ platforms: Target platforms. Supported: tiktok, instagram, youtube,
355
+ linkedin, facebook, pinterest, threads, bluesky, x
356
+
357
+ Keyword Args:
358
+ description: Video description
359
+ first_comment: First comment to post
360
+ scheduled_date: ISO date for scheduling (e.g., "2024-12-25T10:00:00Z")
361
+ timezone: Timezone for scheduled date (e.g., "Europe/Madrid")
362
+ add_to_queue: Add to posting queue
363
+ async_upload: Process asynchronously (default: True)
364
+
365
+ TikTok:
366
+ privacy_level: PUBLIC_TO_EVERYONE, MUTUAL_FOLLOW_FRIENDS,
367
+ FOLLOWER_OF_CREATOR, SELF_ONLY
368
+ disable_duet: Disable duet
369
+ disable_comment: Disable comments
370
+ disable_stitch: Disable stitch
371
+ cover_timestamp: Timestamp in ms for cover
372
+ is_aigc: AI-generated content flag
373
+ post_mode: DIRECT_POST or MEDIA_UPLOAD
374
+ brand_content_toggle: Branded content toggle
375
+ brand_organic_toggle: Brand organic toggle
376
+
377
+ Instagram:
378
+ media_type: REELS or STORIES
379
+ share_to_feed: Share to feed
380
+ collaborators: Comma-separated collaborator usernames
381
+ cover_url: Custom cover URL
382
+ audio_name: Audio track name
383
+ user_tags: Comma-separated user tags
384
+ location_id: Location ID
385
+ thumb_offset: Thumbnail offset
386
+
387
+ YouTube:
388
+ tags: List or comma-separated tags
389
+ categoryId: Category ID (default: "22")
390
+ privacyStatus: public, unlisted, private
391
+ embeddable: Allow embedding
392
+ license: youtube, creativeCommon
393
+ publicStatsViewable: Show public stats
394
+ thumbnail_url: Custom thumbnail URL
395
+ selfDeclaredMadeForKids: Made for kids (COPPA)
396
+ containsSyntheticMedia: AI/synthetic content flag
397
+ defaultLanguage: Title/description language (BCP-47)
398
+ defaultAudioLanguage: Audio language (BCP-47)
399
+ allowedCountries: Comma-separated country codes
400
+ blockedCountries: Comma-separated country codes
401
+ hasPaidProductPlacement: Paid placement flag
402
+ recordingDate: Recording date (ISO 8601)
403
+
404
+ LinkedIn:
405
+ visibility: PUBLIC, CONNECTIONS, LOGGED_IN, CONTAINER
406
+ target_linkedin_page_id: Page ID for organization posts
407
+
408
+ Facebook:
409
+ facebook_page_id: Facebook Page ID
410
+ video_state: PUBLISHED or DRAFT
411
+ facebook_media_type: REELS or STORIES
412
+
413
+ Pinterest:
414
+ pinterest_board_id: Board ID
415
+ pinterest_link: Destination link
416
+ pinterest_cover_image_url: Cover image URL
417
+ pinterest_cover_image_key_frame_time: Key frame time in ms
418
+
419
+ X (Twitter):
420
+ reply_settings: everyone, following, mentionedUsers, subscribers, verified
421
+ nullcast: Promoted-only post
422
+ tagged_user_ids: User IDs to tag
423
+ place_id: Location place ID
424
+ geo_place_id: Geographic place ID
425
+ for_super_followers_only: Exclusive for super followers
426
+ community_id: Community ID
427
+ share_with_followers: Share community post with followers
428
+ x_long_text_as_post: Post long text as single post
429
+
430
+ Threads:
431
+ threads_long_text_as_post: Post long text as single post
60
432
 
61
433
  Returns:
62
- API response JSON.
434
+ API response with request_id for async uploads.
63
435
 
64
436
  Raises:
65
437
  UploadPostError: If upload fails or video file not found.
66
438
  """
67
- data_payload: List[tuple] = []
68
- files_payload: List[tuple] = []
69
- video_file_obj = None # To keep track of the opened file
70
-
439
+ data: List[tuple] = []
440
+ files: List[tuple] = []
441
+ video_file = None
442
+
71
443
  try:
72
- # Prepare video
73
- if isinstance(video_path, str) and \
74
- (video_path.startswith('http://') or video_path.startswith('https://')):
75
- # It's a URL
76
- data_payload.append(('video', video_path))
444
+ video_str = str(video_path)
445
+ if video_str.lower().startswith(("http://", "https://")):
446
+ data.append(("video", video_str))
77
447
  else:
78
- # It's a file path
79
448
  video_p = Path(video_path)
80
449
  if not video_p.exists():
81
450
  raise UploadPostError(f"Video file not found: {video_p}")
82
-
83
- video_file_obj = video_p.open("rb")
84
- files_payload.append(('video', (video_p.name, video_file_obj)))
85
-
86
- # Prepare common parameters
87
- data_payload.append(('title', title))
88
- data_payload.append(('user', user))
89
- for p in platforms:
90
- data_payload.append(('platform[]', p))
91
-
92
- # Add platform-specific parameters from kwargs
93
- for key, value in kwargs.items():
94
- if isinstance(value, bool):
95
- data_payload.append((key, str(value).lower())) # 'true' or 'false'
96
- elif isinstance(value, list):
97
- for v_item in value: # Handles array parameters like 'tags' or 'tagged_user_ids'
98
- data_payload.append((f'{key}[]' if key.endswith('s') else key, str(v_item))) # API expects tags[] for YouTube, etc.
99
- else:
100
- data_payload.append((key, str(value)))
451
+ video_file = video_p.open("rb")
452
+ files.append(("video", (video_p.name, video_file)))
453
+
454
+ self._add_common_params(data, user, title, platforms, **kwargs)
455
+
456
+ if "tiktok" in platforms:
457
+ self._add_tiktok_params(data, is_video=True, **kwargs)
458
+ if "instagram" in platforms:
459
+ self._add_instagram_params(data, is_video=True, **kwargs)
460
+ if "youtube" in platforms:
461
+ self._add_youtube_params(data, **kwargs)
462
+ if "linkedin" in platforms:
463
+ self._add_linkedin_params(data, **kwargs)
464
+ if "facebook" in platforms:
465
+ self._add_facebook_params(data, is_video=True, **kwargs)
466
+ if "pinterest" in platforms:
467
+ self._add_pinterest_params(data, is_video=True, **kwargs)
468
+ if "x" in platforms:
469
+ self._add_x_params(data, is_text=False, **kwargs)
470
+ if "threads" in platforms:
471
+ self._add_threads_params(data, **kwargs)
472
+
473
+ return self._request("/upload", "POST", data=data, files=files if files else None)
101
474
 
102
- response = self.session.post(
103
- f"{self.BASE_URL}/upload", # Endpoint for video is /upload
104
- files=files_payload if files_payload else None,
105
- data=data_payload
106
- )
107
- response.raise_for_status()
108
- return response.json()
109
-
110
- except requests.exceptions.RequestException as e:
111
- raise UploadPostError(
112
- f"API request failed: {str(e)}"
113
- ) from e
114
- except (ValueError, TypeError) as e:
115
- raise UploadPostError(
116
- f"Invalid response format: {str(e)}"
117
- ) from e
118
475
  finally:
119
- if video_file_obj:
120
- video_file_obj.close()
476
+ if video_file:
477
+ video_file.close()
121
478
 
122
479
  def upload_photos(
123
480
  self,
124
481
  photos: List[Union[str, Path]],
482
+ title: str,
125
483
  user: str,
126
484
  platforms: List[str],
127
- title: str,
128
- caption: Optional[str] = None,
129
485
  **kwargs
130
486
  ) -> Dict:
131
487
  """
132
- Upload photos to specified social media platforms.
488
+ Upload photos to social media platforms.
133
489
 
134
490
  Args:
135
- photos: List of photo file paths (str or Path) or photo URLs (str).
136
- user: User identifier.
137
- platforms: List of platforms (e.g., ["tiktok", "instagram"]).
138
- title: Title of the post.
139
- caption: Optional caption/description for the photos.
140
- **kwargs: Platform-specific parameters.
141
- linkedin: visibility (str), target_linkedin_page_id (str)
142
- facebook: facebook_page_id (str)
143
- tiktok: auto_add_music (bool), disable_comment (bool),
144
- branded_content (bool), disclose_commercial (bool),
145
- photo_cover_index (int), description (str)
146
- instagram: media_type (str)
147
- pinterest: pinterest_board_id (str), pinterest_alt_text (str),
148
- pinterest_link (str)
491
+ photos: List of photo file paths or URLs.
492
+ title: Post title/caption.
493
+ user: User identifier (profile name).
494
+ platforms: Target platforms. Supported: tiktok, instagram, linkedin,
495
+ facebook, pinterest, threads, reddit, bluesky, x
496
+
497
+ Keyword Args:
498
+ description: Photo description
499
+ first_comment: First comment to post
500
+ alt_text: Alt text for accessibility
501
+ scheduled_date: ISO date for scheduling
502
+ timezone: Timezone for scheduled date
503
+ add_to_queue: Add to posting queue
504
+ async_upload: Process asynchronously
505
+
506
+ TikTok:
507
+ auto_add_music: Auto add music
508
+ disable_comment: Disable comments
509
+ photo_cover_index: Index of photo for cover (0-based)
510
+ brand_content_toggle: Branded content toggle
511
+ brand_organic_toggle: Brand organic toggle
512
+
513
+ Instagram:
514
+ media_type: IMAGE or STORIES
515
+ collaborators: Comma-separated collaborator usernames
516
+ user_tags: Comma-separated user tags
517
+ location_id: Location ID
518
+
519
+ LinkedIn:
520
+ visibility: PUBLIC (only PUBLIC supported for photos)
521
+ target_linkedin_page_id: Page ID for organization posts
522
+
523
+ Facebook:
524
+ facebook_page_id: Facebook Page ID
525
+
526
+ Pinterest:
527
+ pinterest_board_id: Board ID
528
+ pinterest_alt_text: Alt text
529
+ pinterest_link: Destination link
530
+
531
+ X (Twitter):
532
+ reply_settings: Who can reply
533
+ nullcast: Promoted-only post
534
+ tagged_user_ids: User IDs to tag
535
+ x_long_text_as_post: Post long text as single post
536
+
537
+ Threads:
538
+ threads_long_text_as_post: Post long text as single post
539
+
540
+ Reddit:
541
+ subreddit: Subreddit name (without r/)
542
+ flair_id: Flair template ID
149
543
 
150
544
  Returns:
151
- API response JSON.
545
+ API response.
152
546
 
153
547
  Raises:
154
- UploadPostError: If upload fails or any photo file not found.
548
+ UploadPostError: If upload fails or photo file not found.
155
549
  """
156
- data_payload: List[tuple] = []
157
- files_payload: List[tuple] = []
158
- opened_files: List[object] = [] # To keep track of files to close them later
159
-
550
+ data: List[tuple] = []
551
+ files: List[tuple] = []
552
+ opened_files: List = []
553
+
160
554
  try:
161
- # Prepare photos
162
- for photo_item in photos:
163
- if isinstance(photo_item, str) and \
164
- (photo_item.startswith('http://') or photo_item.startswith('https://')):
165
- # It's a URL
166
- data_payload.append(('photos[]', photo_item))
555
+ for photo in photos:
556
+ photo_str = str(photo)
557
+ if photo_str.lower().startswith(("http://", "https://")):
558
+ data.append(("photos[]", photo_str))
167
559
  else:
168
- # It's a file path
169
- photo_path = Path(photo_item)
170
- if not photo_path.exists():
171
- raise UploadPostError(f"Photo file not found: {photo_path}")
172
-
173
- photo_file_obj = photo_path.open("rb")
174
- opened_files.append(photo_file_obj)
175
- files_payload.append(('photos[]', (photo_path.name, photo_file_obj)))
176
-
177
- # Prepare common parameters
178
- data_payload.append(('user', user))
179
- data_payload.append(('title', title))
180
- if caption is not None:
181
- data_payload.append(('caption', caption))
182
-
183
- for p in platforms:
184
- data_payload.append(('platform[]', p))
185
-
186
- # Add platform-specific parameters from kwargs
187
- for key, value in kwargs.items():
188
- if isinstance(value, bool):
189
- data_payload.append((key, str(value).lower())) # 'true' or 'false'
190
- elif isinstance(value, list):
191
- for v_item in value: # Handles cases where a kwarg value is a list
192
- data_payload.append((key, str(v_item)))
193
- else:
194
- data_payload.append((key, str(value)))
195
-
196
- response = self.session.post(
197
- f"{self.BASE_URL}/upload_photos",
198
- files=files_payload if files_payload else None,
199
- data=data_payload
200
- )
201
- response.raise_for_status()
202
- return response.json()
203
-
204
- except requests.exceptions.RequestException as e:
205
- raise UploadPostError(f"API request failed: {str(e)}") from e
206
- except (ValueError, TypeError) as e: # ValueError for json parsing, TypeError for bad args
207
- raise UploadPostError(f"Data or response format error: {str(e)}") from e
560
+ photo_p = Path(photo)
561
+ if not photo_p.exists():
562
+ raise UploadPostError(f"Photo file not found: {photo_p}")
563
+ photo_file = photo_p.open("rb")
564
+ opened_files.append(photo_file)
565
+ files.append(("photos[]", (photo_p.name, photo_file)))
566
+
567
+ self._add_common_params(data, user, title, platforms, **kwargs)
568
+
569
+ if "tiktok" in platforms:
570
+ self._add_tiktok_params(data, is_video=False, **kwargs)
571
+ if "instagram" in platforms:
572
+ self._add_instagram_params(data, is_video=False, **kwargs)
573
+ if "linkedin" in platforms:
574
+ self._add_linkedin_params(data, **kwargs)
575
+ if "facebook" in platforms:
576
+ self._add_facebook_params(data, is_video=False, **kwargs)
577
+ if "pinterest" in platforms:
578
+ self._add_pinterest_params(data, is_video=False, **kwargs)
579
+ if "x" in platforms:
580
+ self._add_x_params(data, is_text=False, **kwargs)
581
+ if "threads" in platforms:
582
+ self._add_threads_params(data, **kwargs)
583
+ if "reddit" in platforms:
584
+ self._add_reddit_params(data, **kwargs)
585
+
586
+ return self._request("/upload_photos", "POST", data=data, files=files if files else None)
587
+
208
588
  finally:
209
- for f_obj in opened_files:
210
- f_obj.close()
589
+ for f in opened_files:
590
+ f.close()
211
591
 
212
592
  def upload_text(
213
593
  self,
594
+ title: str,
214
595
  user: str,
215
596
  platforms: List[str],
216
- title: str, # As per API docs, 'title' is used for the text content
217
597
  **kwargs
218
598
  ) -> Dict:
219
599
  """
220
- Upload text posts to specified social media platforms.
600
+ Upload text posts to social media platforms.
221
601
 
222
602
  Args:
223
- user: User identifier.
224
- platforms: List of platforms (e.g., ["x", "linkedin"]).
225
- Supported: "linkedin", "x", "facebook", "threads".
226
- title: The text content for the post.
227
- **kwargs: Platform-specific parameters.
228
- linkedin: target_linkedin_page_id (str)
229
- facebook: facebook_page_id (str)
603
+ title: Text content for the post.
604
+ user: User identifier (profile name).
605
+ platforms: Target platforms. Supported: x, linkedin, facebook,
606
+ threads, reddit, bluesky
607
+
608
+ Keyword Args:
609
+ first_comment: First comment to post
610
+ scheduled_date: ISO date for scheduling
611
+ timezone: Timezone for scheduled date
612
+ add_to_queue: Add to posting queue
613
+ async_upload: Process asynchronously
614
+
615
+ LinkedIn:
616
+ target_linkedin_page_id: Page ID for organization posts
617
+
618
+ Facebook:
619
+ facebook_page_id: Facebook Page ID
620
+ facebook_link_url: URL to attach as link preview
621
+
622
+ X (Twitter):
623
+ reply_settings: Who can reply
624
+ post_url: URL to attach
625
+ quote_tweet_id: Tweet ID to quote
626
+ poll_options: Poll options (2-4 options)
627
+ poll_duration: Poll duration in minutes (5-10080)
628
+ poll_reply_settings: Who can reply to poll
629
+ card_uri: Card URI for Twitter Cards
630
+ x_long_text_as_post: Post long text as single post
631
+
632
+ Threads:
633
+ threads_long_text_as_post: Post long text as single post
634
+
635
+ Reddit:
636
+ subreddit: Subreddit name (without r/)
637
+ flair_id: Flair template ID
230
638
 
231
639
  Returns:
232
- API response JSON.
640
+ API response.
233
641
 
234
642
  Raises:
235
643
  UploadPostError: If upload fails.
236
644
  """
237
- data_payload: List[tuple] = []
645
+ data: List[tuple] = []
646
+
647
+ self._add_common_params(data, user, title, platforms, **kwargs)
648
+
649
+ if "linkedin" in platforms:
650
+ self._add_linkedin_params(data, **kwargs)
651
+ if "facebook" in platforms:
652
+ self._add_facebook_params(data, is_video=False, is_text=True, **kwargs)
653
+ if "x" in platforms:
654
+ self._add_x_params(data, is_text=True, **kwargs)
655
+ if "threads" in platforms:
656
+ self._add_threads_params(data, **kwargs)
657
+ if "reddit" in platforms:
658
+ self._add_reddit_params(data, **kwargs)
659
+
660
+ return self._request("/upload_text", "POST", data=data)
238
661
 
662
+ def upload_document(
663
+ self,
664
+ document_path: Union[str, Path],
665
+ title: str,
666
+ user: str,
667
+ description: Optional[str] = None,
668
+ visibility: Optional[str] = None,
669
+ target_linkedin_page_id: Optional[str] = None,
670
+ scheduled_date: Optional[str] = None,
671
+ timezone: Optional[str] = None,
672
+ add_to_queue: Optional[bool] = None,
673
+ async_upload: Optional[bool] = None,
674
+ ) -> Dict:
675
+ """
676
+ Upload a document to LinkedIn (PDF, PPT, PPTX, DOC, DOCX).
677
+
678
+ Args:
679
+ document_path: Path to document file or document URL.
680
+ title: Post title/caption.
681
+ user: User identifier (profile name).
682
+ description: Document description/commentary.
683
+ visibility: PUBLIC, CONNECTIONS, LOGGED_IN, CONTAINER
684
+ target_linkedin_page_id: Page ID for organization posts.
685
+ scheduled_date: ISO date for scheduling.
686
+ timezone: Timezone for scheduled date.
687
+ add_to_queue: Add to posting queue.
688
+ async_upload: Process asynchronously.
689
+
690
+ Returns:
691
+ API response.
692
+
693
+ Raises:
694
+ UploadPostError: If upload fails or document file not found.
695
+ """
696
+ data: List[tuple] = []
697
+ files: List[tuple] = []
698
+ doc_file = None
699
+
239
700
  try:
240
- # Prepare common parameters
241
- data_payload.append(('user', user))
242
- data_payload.append(('title', title)) # 'title' carries the text content
243
-
244
- for p in platforms:
245
- data_payload.append(('platform[]', p))
246
-
247
- # Add platform-specific parameters from kwargs
248
- # (e.g., target_linkedin_page_id, facebook_page_id)
249
- for key, value in kwargs.items():
250
- if isinstance(value, bool): # Should not happen based on current docs for text
251
- data_payload.append((key, str(value).lower()))
252
- elif isinstance(value, list): # Should not happen based on current docs for text
253
- for v_item in value:
254
- data_payload.append((key, str(v_item)))
255
- else:
256
- data_payload.append((key, str(value)))
701
+ doc_str = str(document_path)
702
+ if doc_str.lower().startswith(("http://", "https://")):
703
+ data.append(("document", doc_str))
704
+ else:
705
+ doc_p = Path(document_path)
706
+ if not doc_p.exists():
707
+ raise UploadPostError(f"Document file not found: {doc_p}")
708
+ doc_file = doc_p.open("rb")
709
+ files.append(("document", (doc_p.name, doc_file)))
257
710
 
258
- response = self.session.post(
259
- f"{self.BASE_URL}/upload_text",
260
- data=data_payload
261
- )
262
- response.raise_for_status()
263
- return response.json()
711
+ data.append(("user", user))
712
+ data.append(("title", title))
713
+ data.append(("platform[]", "linkedin"))
714
+
715
+ if description:
716
+ data.append(("description", description))
717
+ if visibility:
718
+ data.append(("visibility", visibility))
719
+ if target_linkedin_page_id:
720
+ data.append(("target_linkedin_page_id", target_linkedin_page_id))
721
+ if scheduled_date:
722
+ data.append(("scheduled_date", scheduled_date))
723
+ if timezone:
724
+ data.append(("timezone", timezone))
725
+ if add_to_queue is not None:
726
+ data.append(("add_to_queue", str(add_to_queue).lower()))
727
+ if async_upload is not None:
728
+ data.append(("async_upload", str(async_upload).lower()))
729
+
730
+ return self._request("/upload_document", "POST", data=data, files=files if files else None)
731
+
732
+ finally:
733
+ if doc_file:
734
+ doc_file.close()
264
735
 
265
- except requests.exceptions.RequestException as e:
266
- raise UploadPostError(f"API request failed: {str(e)}") from e
267
- except (ValueError, TypeError) as e:
268
- raise UploadPostError(f"Data or response format error: {str(e)}") from e
736
+ # ==================== Status & History ====================
737
+
738
+ def get_status(self, request_id: str) -> Dict:
739
+ """
740
+ Get the status of an async upload.
741
+
742
+ Args:
743
+ request_id: The request_id from an async upload.
744
+
745
+ Returns:
746
+ Upload status.
747
+ """
748
+ return self._request("/uploadposts/status", "GET", params={"request_id": request_id})
749
+
750
+ def get_history(self, page: int = 1, limit: int = 20) -> Dict:
751
+ """
752
+ Get upload history.
753
+
754
+ Args:
755
+ page: Page number.
756
+ limit: Items per page (20, 50, or 100).
757
+
758
+ Returns:
759
+ Upload history.
760
+ """
761
+ return self._request("/uploadposts/history", "GET", params={"page": page, "limit": limit})
762
+
763
+ def get_analytics(self, profile_username: str, platforms: Optional[List[str]] = None) -> Dict:
764
+ """
765
+ Get analytics for a profile.
766
+
767
+ Args:
768
+ profile_username: Profile username.
769
+ platforms: Filter by platforms (instagram, linkedin, facebook, x).
770
+
771
+ Returns:
772
+ Analytics data.
773
+ """
774
+ params = {}
775
+ if platforms:
776
+ params["platforms"] = ",".join(platforms)
777
+ return self._request(f"/analytics/{profile_username}", "GET", params=params if params else None)
778
+
779
+ # ==================== Scheduled Posts ====================
780
+
781
+ def list_scheduled(self) -> Dict:
782
+ """
783
+ List scheduled posts.
784
+
785
+ Returns:
786
+ List of scheduled posts.
787
+ """
788
+ return self._request("/uploadposts/schedule", "GET")
789
+
790
+ def cancel_scheduled(self, job_id: str) -> Dict:
791
+ """
792
+ Cancel a scheduled post.
793
+
794
+ Args:
795
+ job_id: Scheduled job ID.
796
+
797
+ Returns:
798
+ Cancellation result.
799
+ """
800
+ return self._request(f"/uploadposts/schedule/{job_id}", "DELETE")
801
+
802
+ def edit_scheduled(
803
+ self,
804
+ job_id: str,
805
+ scheduled_date: Optional[str] = None,
806
+ timezone: Optional[str] = None
807
+ ) -> Dict:
808
+ """
809
+ Edit a scheduled post.
810
+
811
+ Args:
812
+ job_id: Scheduled job ID.
813
+ scheduled_date: New scheduled date (ISO 8601).
814
+ timezone: New timezone.
815
+
816
+ Returns:
817
+ Edit result.
818
+ """
819
+ body = {}
820
+ if scheduled_date:
821
+ body["scheduled_date"] = scheduled_date
822
+ if timezone:
823
+ body["timezone"] = timezone
824
+ return self._request(f"/uploadposts/schedule/{job_id}", "POST", json_data=body)
825
+
826
+ # ==================== User Management ====================
827
+
828
+ def list_users(self) -> Dict:
829
+ """
830
+ List all users/profiles.
831
+
832
+ Returns:
833
+ List of users.
834
+ """
835
+ return self._request("/uploadposts/users", "GET")
836
+
837
+ def create_user(self, username: str) -> Dict:
838
+ """
839
+ Create a new user/profile.
840
+
841
+ Args:
842
+ username: Profile name to create.
843
+
844
+ Returns:
845
+ Created user.
846
+ """
847
+ return self._request("/uploadposts/users", "POST", json_data={"username": username})
848
+
849
+ def delete_user(self, username: str) -> Dict:
850
+ """
851
+ Delete a user/profile.
852
+
853
+ Args:
854
+ username: Profile name to delete.
855
+
856
+ Returns:
857
+ Deletion result.
858
+ """
859
+ return self._request("/uploadposts/users", "DELETE", json_data={"username": username})
860
+
861
+ def generate_jwt(
862
+ self,
863
+ username: str,
864
+ redirect_url: Optional[str] = None,
865
+ logo_image: Optional[str] = None,
866
+ redirect_button_text: Optional[str] = None,
867
+ platforms: Optional[List[str]] = None
868
+ ) -> Dict:
869
+ """
870
+ Generate a JWT for platform integration.
871
+ Used when integrating Upload-Post into your own platform.
872
+
873
+ Args:
874
+ username: Profile username.
875
+ redirect_url: URL to redirect after linking.
876
+ logo_image: Logo image URL for the linking page.
877
+ redirect_button_text: Text for redirect button.
878
+ platforms: Platforms to show for connection.
879
+
880
+ Returns:
881
+ JWT and connection URL.
882
+ """
883
+ body: Dict[str, Any] = {"username": username}
884
+ if redirect_url:
885
+ body["redirect_url"] = redirect_url
886
+ if logo_image:
887
+ body["logo_image"] = logo_image
888
+ if redirect_button_text:
889
+ body["redirect_button_text"] = redirect_button_text
890
+ if platforms:
891
+ body["platforms"] = platforms
892
+ return self._request("/uploadposts/users/generate-jwt", "POST", json_data=body)
893
+
894
+ def validate_jwt(self, jwt: str) -> Dict:
895
+ """
896
+ Validate a JWT token.
897
+
898
+ Args:
899
+ jwt: JWT token to validate.
900
+
901
+ Returns:
902
+ Validation result.
903
+ """
904
+ return self._request("/uploadposts/users/validate-jwt", "POST", json_data={"jwt": jwt})
905
+
906
+ # ==================== Helper Endpoints ====================
907
+
908
+ def get_facebook_pages(self, profile: Optional[str] = None) -> Dict:
909
+ """
910
+ Get Facebook pages for a profile.
911
+
912
+ Args:
913
+ profile: Profile username.
914
+
915
+ Returns:
916
+ List of Facebook pages.
917
+ """
918
+ params = {"profile": profile} if profile else None
919
+ return self._request("/uploadposts/facebook/pages", "GET", params=params)
920
+
921
+ def get_linkedin_pages(self, profile: Optional[str] = None) -> Dict:
922
+ """
923
+ Get LinkedIn pages for a profile.
924
+
925
+ Args:
926
+ profile: Profile username.
927
+
928
+ Returns:
929
+ List of LinkedIn pages.
930
+ """
931
+ params = {"profile": profile} if profile else None
932
+ return self._request("/uploadposts/linkedin/pages", "GET", params=params)
933
+
934
+ def get_pinterest_boards(self, profile: Optional[str] = None) -> Dict:
935
+ """
936
+ Get Pinterest boards for a profile.
937
+
938
+ Args:
939
+ profile: Profile username.
940
+
941
+ Returns:
942
+ List of Pinterest boards.
943
+ """
944
+ params = {"profile": profile} if profile else None
945
+ return self._request("/uploadposts/pinterest/boards", "GET", params=params)