upload-post 2.0.0__tar.gz → 2.1.1__tar.gz

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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: upload-post
3
- Version: 2.0.0
3
+ Version: 2.1.1
4
4
  Summary: Cross-platform social media upload for TikTok, Instagram, YouTube, LinkedIn, Facebook, Pinterest, Threads, Reddit, Bluesky, and X (Twitter)
5
5
  Home-page: https://www.upload-post.com/
6
6
  Author: Upload-Post
@@ -162,6 +162,13 @@ status = client.get_status("request_id_from_upload")
162
162
  print(status)
163
163
  ```
164
164
 
165
+ For scheduled or queued posts, check the status using the job_id:
166
+
167
+ ```python
168
+ status = client.get_job_status("job_id_from_scheduled_post")
169
+ print(status)
170
+ ```
171
+
165
172
  ### Get Upload History
166
173
 
167
174
  ```python
@@ -280,7 +287,8 @@ boards = client.get_pinterest_boards("my-profile")
280
287
  ### Facebook
281
288
  - `facebook_page_id` - Facebook Page ID (required)
282
289
  - `video_state` - PUBLISHED, DRAFT
283
- - `facebook_media_type` - REELS, STORIES
290
+ - `facebook_media_type` - REELS, STORIES, or VIDEO (normal page video)
291
+ - `thumbnail_url` - Thumbnail URL for normal page videos (only when `facebook_media_type` is VIDEO)
284
292
  - `facebook_link_url` - URL for text posts
285
293
 
286
294
  ### Pinterest
@@ -303,9 +311,12 @@ boards = client.get_pinterest_boards("my-profile")
303
311
  - `share_with_followers` - Share community post with followers
304
312
  - `card_uri` - Card URI for Twitter Cards
305
313
  - `x_long_text_as_post` - Post long text as single post
314
+ - `x_thread_image_layout` - Comma-separated image layout for thread (e.g. "4,4" or "2,3,1"). Each value 1-4, total must equal image count. Auto-chunks into groups of 4 when >4 images.
306
315
 
307
316
  ### Threads
308
317
  - `threads_long_text_as_post` - Post long text as single post (vs thread)
318
+ - `threads_thread_media_layout` - Comma-separated list of how many media items to include in each Threads post (e.g. "5,5" or "3,4,3"). Each value 1-10, total must equal media count. Auto-chunks into groups of 10 when >10 items.
319
+ - `threads_topic_tag` - Topic tag for the Threads post (1-50 characters, no periods or ampersands). One tag per post. Helps increase reach.
309
320
 
310
321
  ### Reddit
311
322
  - `subreddit` - Subreddit name (without r/)
@@ -325,6 +336,7 @@ These options work across all upload methods:
325
336
  | `scheduled_date` | ISO date for scheduling |
326
337
  | `timezone` | Timezone for scheduled date |
327
338
  | `add_to_queue` | Add to posting queue |
339
+ | `max_posts_per_slot` | Max posts per queue slot (overrides profile setting) |
328
340
  | `async_upload` | Process asynchronously (default: True) |
329
341
 
330
342
  ## Error Handling
@@ -350,3 +362,5 @@ except UploadPostError as e:
350
362
  ## License
351
363
 
352
364
  MIT
365
+
366
+ <!-- deployed 2026-03-16 17:49 UTC -->
@@ -124,6 +124,13 @@ status = client.get_status("request_id_from_upload")
124
124
  print(status)
125
125
  ```
126
126
 
127
+ For scheduled or queued posts, check the status using the job_id:
128
+
129
+ ```python
130
+ status = client.get_job_status("job_id_from_scheduled_post")
131
+ print(status)
132
+ ```
133
+
127
134
  ### Get Upload History
128
135
 
129
136
  ```python
@@ -242,7 +249,8 @@ boards = client.get_pinterest_boards("my-profile")
242
249
  ### Facebook
243
250
  - `facebook_page_id` - Facebook Page ID (required)
244
251
  - `video_state` - PUBLISHED, DRAFT
245
- - `facebook_media_type` - REELS, STORIES
252
+ - `facebook_media_type` - REELS, STORIES, or VIDEO (normal page video)
253
+ - `thumbnail_url` - Thumbnail URL for normal page videos (only when `facebook_media_type` is VIDEO)
246
254
  - `facebook_link_url` - URL for text posts
247
255
 
248
256
  ### Pinterest
@@ -265,9 +273,12 @@ boards = client.get_pinterest_boards("my-profile")
265
273
  - `share_with_followers` - Share community post with followers
266
274
  - `card_uri` - Card URI for Twitter Cards
267
275
  - `x_long_text_as_post` - Post long text as single post
276
+ - `x_thread_image_layout` - Comma-separated image layout for thread (e.g. "4,4" or "2,3,1"). Each value 1-4, total must equal image count. Auto-chunks into groups of 4 when >4 images.
268
277
 
269
278
  ### Threads
270
279
  - `threads_long_text_as_post` - Post long text as single post (vs thread)
280
+ - `threads_thread_media_layout` - Comma-separated list of how many media items to include in each Threads post (e.g. "5,5" or "3,4,3"). Each value 1-10, total must equal media count. Auto-chunks into groups of 10 when >10 items.
281
+ - `threads_topic_tag` - Topic tag for the Threads post (1-50 characters, no periods or ampersands). One tag per post. Helps increase reach.
271
282
 
272
283
  ### Reddit
273
284
  - `subreddit` - Subreddit name (without r/)
@@ -287,6 +298,7 @@ These options work across all upload methods:
287
298
  | `scheduled_date` | ISO date for scheduling |
288
299
  | `timezone` | Timezone for scheduled date |
289
300
  | `add_to_queue` | Add to posting queue |
301
+ | `max_posts_per_slot` | Max posts per queue slot (overrides profile setting) |
290
302
  | `async_upload` | Process asynchronously (default: True) |
291
303
 
292
304
  ## Error Handling
@@ -312,3 +324,5 @@ except UploadPostError as e:
312
324
  ## License
313
325
 
314
326
  MIT
327
+
328
+ <!-- deployed 2026-03-16 17:49 UTC -->
@@ -5,7 +5,7 @@ with open("README.md", "r", encoding="utf-8") as fh:
5
5
 
6
6
  setup(
7
7
  name="upload-post",
8
- version="2.0.0",
8
+ version="2.1.1",
9
9
  author="Upload-Post",
10
10
  author_email="hi@img2html.com",
11
11
  description="Cross-platform social media upload for TikTok, Instagram, YouTube, LinkedIn, Facebook, Pinterest, Threads, Reddit, Bluesky, and X (Twitter)",
@@ -15,7 +15,7 @@ Example:
15
15
  ... )
16
16
  """
17
17
 
18
- __version__ = "2.0.0"
18
+ __version__ = "2.1.1"
19
19
 
20
20
  from .api_client import UploadPostClient, UploadPostError
21
21
 
@@ -6,7 +6,7 @@ Facebook, Pinterest, Threads, Reddit, Bluesky, and X (Twitter).
6
6
  """
7
7
 
8
8
  from pathlib import Path
9
- from typing import Dict, List, Union, Optional, Any, Literal
9
+ from typing import Dict, List, Union, Optional, Any
10
10
  import requests
11
11
 
12
12
 
@@ -45,7 +45,8 @@ class UploadPostClient:
45
45
  self.session = requests.Session()
46
46
  self.session.headers.update({
47
47
  "Authorization": f"Apikey {self.api_key}",
48
- "User-Agent": "upload-post-python-client/2.0.0"
48
+ "User-Agent": "upload-post-python-client/2.0.0",
49
+ "X-Upload-Post-Source": "pip",
49
50
  })
50
51
 
51
52
  def _request(
@@ -85,7 +86,7 @@ class UploadPostClient:
85
86
  try:
86
87
  error_data = e.response.json()
87
88
  error_msg = error_data.get('message') or error_data.get('detail') or str(error_data)
88
- except:
89
+ except (ValueError, KeyError):
89
90
  pass
90
91
  raise UploadPostError(f"API request failed: {error_msg}") from e
91
92
 
@@ -93,22 +94,24 @@ class UploadPostClient:
93
94
  self,
94
95
  data: List[tuple],
95
96
  user: str,
96
- title: str,
97
+ title: Optional[str],
97
98
  platforms: List[str],
98
99
  first_comment: Optional[str] = None,
99
100
  alt_text: Optional[str] = None,
100
101
  scheduled_date: Optional[str] = None,
101
102
  timezone: Optional[str] = None,
102
103
  add_to_queue: Optional[bool] = None,
104
+ max_posts_per_slot: Optional[int] = None,
103
105
  async_upload: Optional[bool] = None,
104
106
  **kwargs
105
107
  ):
106
108
  """Add common upload parameters."""
107
109
  data.append(("user", user))
108
- data.append(("title", title))
110
+ if title:
111
+ data.append(("title", title))
109
112
  for p in platforms:
110
113
  data.append(("platform[]", p))
111
-
114
+
112
115
  if first_comment:
113
116
  data.append(("first_comment", first_comment))
114
117
  if alt_text:
@@ -119,6 +122,8 @@ class UploadPostClient:
119
122
  data.append(("timezone", timezone))
120
123
  if add_to_queue is not None:
121
124
  data.append(("add_to_queue", str(add_to_queue).lower()))
125
+ if max_posts_per_slot is not None:
126
+ data.append(("max_posts_per_slot", str(max_posts_per_slot)))
122
127
  if async_upload is not None:
123
128
  data.append(("async_upload", str(async_upload).lower()))
124
129
 
@@ -144,7 +149,7 @@ class UploadPostClient:
144
149
  comment_overrides = [
145
150
  "instagram_first_comment", "facebook_first_comment", "x_first_comment",
146
151
  "threads_first_comment", "youtube_first_comment", "reddit_first_comment",
147
- "bluesky_first_comment"
152
+ "bluesky_first_comment", "linkedin_first_comment"
148
153
  ]
149
154
  for key in comment_overrides:
150
155
  if kwargs.get(key):
@@ -178,7 +183,7 @@ class UploadPostClient:
178
183
  if kwargs.get("photo_cover_index") is not None:
179
184
  data.append(("photo_cover_index", str(kwargs["photo_cover_index"])))
180
185
 
181
- def _add_instagram_params(self, data: List[tuple], is_video: bool = True, **kwargs):
186
+ def _add_instagram_params(self, data: List[tuple], is_video: bool = True, files: List[tuple] | None = None, **kwargs):
182
187
  """Add Instagram-specific parameters."""
183
188
  if kwargs.get("media_type"):
184
189
  data.append(("media_type", kwargs["media_type"]))
@@ -188,12 +193,20 @@ class UploadPostClient:
188
193
  data.append(("user_tags", kwargs["user_tags"]))
189
194
  if kwargs.get("location_id"):
190
195
  data.append(("location_id", kwargs["location_id"]))
191
-
196
+
192
197
  if is_video:
193
198
  if kwargs.get("share_to_feed") is not None:
194
199
  data.append(("share_to_feed", str(kwargs["share_to_feed"]).lower()))
195
200
  if kwargs.get("cover_url"):
196
- data.append(("cover_url", kwargs["cover_url"]))
201
+ cover_val = str(kwargs["cover_url"])
202
+ if cover_val.lower().startswith(("http://", "https://")):
203
+ data.append(("cover_url", cover_val))
204
+ elif files is not None:
205
+ cover_path = Path(cover_val)
206
+ if cover_path.exists():
207
+ files.append(("cover_image", (cover_path.name, cover_path.open("rb"))))
208
+ else:
209
+ data.append(("cover_url", cover_val))
197
210
  if kwargs.get("audio_name"):
198
211
  data.append(("audio_name", kwargs["audio_name"]))
199
212
  if kwargs.get("thumb_offset"):
@@ -236,12 +249,15 @@ class UploadPostClient:
236
249
  if kwargs.get("recordingDate"):
237
250
  data.append(("recordingDate", kwargs["recordingDate"]))
238
251
 
239
- def _add_linkedin_params(self, data: List[tuple], **kwargs):
252
+ def _add_linkedin_params(self, data: List[tuple], is_text: bool = False, **kwargs):
240
253
  """Add LinkedIn-specific parameters."""
241
254
  if kwargs.get("visibility"):
242
255
  data.append(("visibility", kwargs["visibility"]))
243
256
  if kwargs.get("target_linkedin_page_id"):
244
257
  data.append(("target_linkedin_page_id", kwargs["target_linkedin_page_id"]))
258
+ if is_text and (kwargs.get("linkedin_link_url") or kwargs.get("link_url")):
259
+ link = kwargs.get("linkedin_link_url") or kwargs.get("link_url")
260
+ data.append(("linkedin_link_url", link))
245
261
 
246
262
  def _add_facebook_params(self, data: List[tuple], is_video: bool = False, is_text: bool = False, **kwargs):
247
263
  """Add Facebook-specific parameters."""
@@ -253,7 +269,9 @@ class UploadPostClient:
253
269
  data.append(("video_state", kwargs["video_state"]))
254
270
  if kwargs.get("facebook_media_type"):
255
271
  data.append(("facebook_media_type", kwargs["facebook_media_type"]))
256
-
272
+ if kwargs.get("thumbnail_url"):
273
+ data.append(("thumbnail_url", kwargs["thumbnail_url"]))
274
+
257
275
  if is_text and kwargs.get("facebook_link_url"):
258
276
  data.append(("facebook_link_url", kwargs["facebook_link_url"]))
259
277
 
@@ -307,6 +325,8 @@ class UploadPostClient:
307
325
  data.append(("tagged_user_ids[]", uid))
308
326
  if kwargs.get("place_id"):
309
327
  data.append(("place_id", kwargs["place_id"]))
328
+ if kwargs.get("x_thread_image_layout"):
329
+ data.append(("x_thread_image_layout", kwargs["x_thread_image_layout"]))
310
330
  else:
311
331
  if kwargs.get("post_url"):
312
332
  data.append(("post_url", kwargs["post_url"]))
@@ -328,20 +348,28 @@ class UploadPostClient:
328
348
  """Add Threads-specific parameters."""
329
349
  if kwargs.get("threads_long_text_as_post") is not None:
330
350
  data.append(("threads_long_text_as_post", str(kwargs["threads_long_text_as_post"]).lower()))
351
+ if kwargs.get("threads_thread_media_layout"):
352
+ data.append(("threads_thread_media_layout", kwargs["threads_thread_media_layout"]))
353
+ if kwargs.get("threads_topic_tag"):
354
+ data.append(("threads_topic_tag", kwargs["threads_topic_tag"]))
331
355
 
332
- def _add_reddit_params(self, data: List[tuple], **kwargs):
356
+ def _add_reddit_params(self, data: List[tuple], is_text: bool = False, **kwargs):
333
357
  """Add Reddit-specific parameters."""
334
358
  if kwargs.get("subreddit"):
335
359
  data.append(("subreddit", kwargs["subreddit"]))
336
360
  if kwargs.get("flair_id"):
337
361
  data.append(("flair_id", kwargs["flair_id"]))
362
+ if is_text:
363
+ reddit_link = kwargs.get("reddit_link_url") or kwargs.get("link_url")
364
+ if reddit_link:
365
+ data.append(("reddit_link_url", reddit_link))
338
366
 
339
367
  def upload_video(
340
368
  self,
341
369
  video_path: Union[str, Path],
342
- title: str,
343
- user: str,
344
- platforms: List[str],
370
+ title: Optional[str] = None,
371
+ user: str = "",
372
+ platforms: Optional[List[str]] = None,
345
373
  **kwargs
346
374
  ) -> Dict:
347
375
  """
@@ -349,9 +377,10 @@ class UploadPostClient:
349
377
 
350
378
  Args:
351
379
  video_path: Path to video file or video URL.
352
- title: Video title/caption.
380
+ title: Video title/caption. Required for YouTube and Reddit.
381
+ Optional for TikTok, Instagram, Facebook, LinkedIn, X, Threads, Bluesky, Pinterest.
353
382
  user: User identifier (profile name).
354
- platforms: Target platforms. Supported: tiktok, instagram, youtube,
383
+ platforms: Target platforms. Supported: tiktok, instagram, youtube,
355
384
  linkedin, facebook, pinterest, threads, bluesky, x
356
385
 
357
386
  Keyword Args:
@@ -378,7 +407,7 @@ class UploadPostClient:
378
407
  media_type: REELS or STORIES
379
408
  share_to_feed: Share to feed
380
409
  collaborators: Comma-separated collaborator usernames
381
- cover_url: Custom cover URL
410
+ cover_url: Custom cover URL or file path. URLs are sent directly; file paths are uploaded as binary.
382
411
  audio_name: Audio track name
383
412
  user_tags: Comma-separated user tags
384
413
  location_id: Location ID
@@ -408,7 +437,8 @@ class UploadPostClient:
408
437
  Facebook:
409
438
  facebook_page_id: Facebook Page ID
410
439
  video_state: PUBLISHED or DRAFT
411
- facebook_media_type: REELS or STORIES
440
+ facebook_media_type: REELS, STORIES, or VIDEO
441
+ thumbnail_url: Thumbnail URL for normal page videos (VIDEO type only)
412
442
 
413
443
  Pinterest:
414
444
  pinterest_board_id: Board ID
@@ -429,6 +459,7 @@ class UploadPostClient:
429
459
 
430
460
  Threads:
431
461
  threads_long_text_as_post: Post long text as single post
462
+ threads_topic_tag: Topic tag for the post (1-50 chars, no periods or ampersands)
432
463
 
433
464
  Returns:
434
465
  API response with request_id for async uploads.
@@ -456,7 +487,7 @@ class UploadPostClient:
456
487
  if "tiktok" in platforms:
457
488
  self._add_tiktok_params(data, is_video=True, **kwargs)
458
489
  if "instagram" in platforms:
459
- self._add_instagram_params(data, is_video=True, **kwargs)
490
+ self._add_instagram_params(data, is_video=True, files=files, **kwargs)
460
491
  if "youtube" in platforms:
461
492
  self._add_youtube_params(data, **kwargs)
462
493
  if "linkedin" in platforms:
@@ -479,9 +510,9 @@ class UploadPostClient:
479
510
  def upload_photos(
480
511
  self,
481
512
  photos: List[Union[str, Path]],
482
- title: str,
483
- user: str,
484
- platforms: List[str],
513
+ title: Optional[str] = None,
514
+ user: str = "",
515
+ platforms: Optional[List[str]] = None,
485
516
  **kwargs
486
517
  ) -> Dict:
487
518
  """
@@ -489,7 +520,8 @@ class UploadPostClient:
489
520
 
490
521
  Args:
491
522
  photos: List of photo file paths or URLs.
492
- title: Post title/caption.
523
+ title: Post title/caption. Required for Reddit.
524
+ Optional for TikTok, Instagram, Facebook, LinkedIn, X, Threads, Bluesky, Pinterest.
493
525
  user: User identifier (profile name).
494
526
  platforms: Target platforms. Supported: tiktok, instagram, linkedin,
495
527
  facebook, pinterest, threads, reddit, bluesky, x
@@ -533,14 +565,27 @@ class UploadPostClient:
533
565
  nullcast: Promoted-only post
534
566
  tagged_user_ids: User IDs to tag
535
567
  x_long_text_as_post: Post long text as single post
536
-
568
+ x_thread_image_layout: Comma-separated image layout for thread
569
+ (e.g. "4,4", "2,3,1", or "0,1"). Each value 0-4, total must
570
+ equal image count. 0 means no images for that tweet (useful
571
+ for URL preview cards). Auto-chunks into groups of 4 if >4
572
+ images and no layout specified.
573
+
537
574
  Threads:
538
575
  threads_long_text_as_post: Post long text as single post
539
-
576
+ threads_thread_media_layout: Comma-separated media layout for thread
577
+ (e.g. "5,5", "3,4,3", or "0,1"). Each value 0-10, total must
578
+ equal media count. 0 means no media for that post. Auto-chunks
579
+ into groups of 10 if >10 items and no layout specified.
580
+ threads_topic_tag: Topic tag for the post (1-50 chars, no periods or ampersands)
581
+
540
582
  Reddit:
541
583
  subreddit: Subreddit name (without r/)
542
584
  flair_id: Flair template ID
543
585
 
586
+ first_comment_media: List of file paths to attach as images in
587
+ the first comment. Supported on Reddit and X.
588
+
544
589
  Returns:
545
590
  API response.
546
591
 
@@ -550,7 +595,7 @@ class UploadPostClient:
550
595
  data: List[tuple] = []
551
596
  files: List[tuple] = []
552
597
  opened_files: List = []
553
-
598
+
554
599
  try:
555
600
  for photo in photos:
556
601
  photo_str = str(photo)
@@ -563,9 +608,9 @@ class UploadPostClient:
563
608
  photo_file = photo_p.open("rb")
564
609
  opened_files.append(photo_file)
565
610
  files.append(("photos[]", (photo_p.name, photo_file)))
566
-
611
+
567
612
  self._add_common_params(data, user, title, platforms, **kwargs)
568
-
613
+
569
614
  if "tiktok" in platforms:
570
615
  self._add_tiktok_params(data, is_video=False, **kwargs)
571
616
  if "instagram" in platforms:
@@ -582,9 +627,19 @@ class UploadPostClient:
582
627
  self._add_threads_params(data, **kwargs)
583
628
  if "reddit" in platforms:
584
629
  self._add_reddit_params(data, **kwargs)
585
-
630
+
631
+ first_comment_media = kwargs.get("first_comment_media")
632
+ if first_comment_media:
633
+ for media_path in first_comment_media:
634
+ p = Path(media_path)
635
+ if not p.exists():
636
+ raise UploadPostError(f"First comment media file not found: {media_path}")
637
+ f = open(p, "rb")
638
+ opened_files.append(f)
639
+ files.append(("first_comment_media[]", (p.name, f)))
640
+
586
641
  return self._request("/upload_photos", "POST", data=data, files=files if files else None)
587
-
642
+
588
643
  finally:
589
644
  for f in opened_files:
590
645
  f.close()
@@ -611,14 +666,20 @@ class UploadPostClient:
611
666
  timezone: Timezone for scheduled date
612
667
  add_to_queue: Add to posting queue
613
668
  async_upload: Process asynchronously
614
-
669
+ link_url: Generic URL for link preview card (works for LinkedIn,
670
+ Bluesky, Facebook). Platform-specific params take priority.
671
+
615
672
  LinkedIn:
616
673
  target_linkedin_page_id: Page ID for organization posts
617
-
674
+ linkedin_link_url: URL to attach as link preview on LinkedIn
675
+
618
676
  Facebook:
619
677
  facebook_page_id: Facebook Page ID
620
- facebook_link_url: URL to attach as link preview
621
-
678
+ facebook_link_url: URL to attach as link preview on Facebook
679
+
680
+ Bluesky:
681
+ bluesky_link_url: URL to attach as external embed link preview
682
+
622
683
  X (Twitter):
623
684
  reply_settings: Who can reply
624
685
  post_url: URL to attach
@@ -628,13 +689,20 @@ class UploadPostClient:
628
689
  poll_reply_settings: Who can reply to poll
629
690
  card_uri: Card URI for Twitter Cards
630
691
  x_long_text_as_post: Post long text as single post
631
-
692
+
632
693
  Threads:
633
694
  threads_long_text_as_post: Post long text as single post
634
-
695
+ threads_topic_tag: Topic tag for the post (1-50 chars, no periods or ampersands)
696
+
635
697
  Reddit:
636
698
  subreddit: Subreddit name (without r/)
637
699
  flair_id: Flair template ID
700
+ reddit_link_url: URL for link post. Creates a Reddit link post
701
+ (kind: "link") instead of a text post. Overrides `link_url`
702
+ for Reddit.
703
+
704
+ first_comment_media: List of file paths to attach as images in
705
+ the first comment. Supported on Reddit and X.
638
706
 
639
707
  Returns:
640
708
  API response.
@@ -643,11 +711,16 @@ class UploadPostClient:
643
711
  UploadPostError: If upload fails.
644
712
  """
645
713
  data: List[tuple] = []
646
-
714
+ files: Optional[List[tuple]] = None
715
+
647
716
  self._add_common_params(data, user, title, platforms, **kwargs)
648
-
717
+
718
+ # Generic link_url support
719
+ if kwargs.get("link_url"):
720
+ data.append(("link_url", kwargs["link_url"]))
721
+
649
722
  if "linkedin" in platforms:
650
- self._add_linkedin_params(data, **kwargs)
723
+ self._add_linkedin_params(data, is_text=True, **kwargs)
651
724
  if "facebook" in platforms:
652
725
  self._add_facebook_params(data, is_video=False, is_text=True, **kwargs)
653
726
  if "x" in platforms:
@@ -655,9 +728,29 @@ class UploadPostClient:
655
728
  if "threads" in platforms:
656
729
  self._add_threads_params(data, **kwargs)
657
730
  if "reddit" in platforms:
658
- self._add_reddit_params(data, **kwargs)
659
-
660
- return self._request("/upload_text", "POST", data=data)
731
+ self._add_reddit_params(data, is_text=True, **kwargs)
732
+ if "bluesky" in platforms:
733
+ bluesky_link = kwargs.get("bluesky_link_url")
734
+ if bluesky_link:
735
+ data.append(("bluesky_link_url", bluesky_link))
736
+
737
+ first_comment_media = kwargs.get("first_comment_media")
738
+ opened_files: List = []
739
+ if first_comment_media:
740
+ files = []
741
+ for media_path in first_comment_media:
742
+ p = Path(media_path)
743
+ if not p.exists():
744
+ raise UploadPostError(f"First comment media file not found: {media_path}")
745
+ f = open(p, "rb")
746
+ opened_files.append(f)
747
+ files.append(("first_comment_media[]", (p.name, f)))
748
+
749
+ try:
750
+ return self._request("/upload_text", "POST", data=data, files=files)
751
+ finally:
752
+ for f in opened_files:
753
+ f.close()
661
754
 
662
755
  def upload_document(
663
756
  self,
@@ -737,7 +830,7 @@ class UploadPostClient:
737
830
 
738
831
  def get_status(self, request_id: str) -> Dict:
739
832
  """
740
- Get the status of an async upload.
833
+ Get the status of an async upload by request ID.
741
834
 
742
835
  Args:
743
836
  request_id: The request_id from an async upload.
@@ -747,6 +840,18 @@ class UploadPostClient:
747
840
  """
748
841
  return self._request("/uploadposts/status", "GET", params={"request_id": request_id})
749
842
 
843
+ def get_job_status(self, job_id: str) -> Dict:
844
+ """
845
+ Get the status of a scheduled or queued upload by job ID.
846
+
847
+ Args:
848
+ job_id: The job_id from a scheduled or queued upload.
849
+
850
+ Returns:
851
+ Upload status.
852
+ """
853
+ return self._request("/uploadposts/status", "GET", params={"job_id": job_id})
854
+
750
855
  def get_history(self, page: int = 1, limit: int = 20) -> Dict:
751
856
  """
752
857
  Get upload history.
@@ -760,22 +865,114 @@ class UploadPostClient:
760
865
  """
761
866
  return self._request("/uploadposts/history", "GET", params={"page": page, "limit": limit})
762
867
 
763
- def get_analytics(self, profile_username: str, platforms: Optional[List[str]] = None) -> Dict:
868
+ def get_analytics(self, profile_username: str, platforms: Optional[List[str]] = None,
869
+ page_id: Optional[str] = None, page_urn: Optional[str] = None) -> Dict:
764
870
  """
765
871
  Get analytics for a profile.
766
872
 
767
873
  Args:
768
874
  profile_username: Profile username.
769
- platforms: Filter by platforms (instagram, linkedin, facebook, x).
875
+ platforms: Filter by platforms (instagram, linkedin, facebook, x, youtube, tiktok, threads, pinterest, reddit).
876
+ page_id: Facebook Page ID (required for Facebook analytics).
877
+ page_urn: LinkedIn page URN (defaults to "me" for personal profile).
770
878
 
771
879
  Returns:
772
- Analytics data.
880
+ Analytics data per platform. For Instagram, the response includes both
881
+ 'views' (official Instagram metric) and 'impressions' (backwards-compatible alias).
773
882
  """
774
883
  params = {}
775
884
  if platforms:
776
885
  params["platforms"] = ",".join(platforms)
886
+ if page_id:
887
+ params["page_id"] = page_id
888
+ if page_urn:
889
+ params["page_urn"] = page_urn
777
890
  return self._request(f"/analytics/{profile_username}", "GET", params=params if params else None)
778
891
 
892
+ def get_total_impressions(
893
+ self,
894
+ profile_username: str,
895
+ period: Optional[str] = None,
896
+ start_date: Optional[str] = None,
897
+ end_date: Optional[str] = None,
898
+ date: Optional[str] = None,
899
+ platforms: Optional[List[str]] = None,
900
+ breakdown: bool = False,
901
+ metrics: Optional[List[str]] = None,
902
+ ) -> Dict:
903
+ """
904
+ Get total impressions for a profile from daily snapshots.
905
+
906
+ Args:
907
+ profile_username: Profile username.
908
+ period: Period shortcut (last_day, last_week, last_month, last_3months, last_year).
909
+ start_date: Start date in YYYY-MM-DD format.
910
+ end_date: End date in YYYY-MM-DD format.
911
+ date: Single date in YYYY-MM-DD format.
912
+ platforms: Filter by platforms.
913
+ breakdown: Include per-platform and per-day breakdown.
914
+ metrics: Specific metrics to aggregate (e.g., ["likes", "comments", "shares", "views"]).
915
+
916
+ Returns:
917
+ Total impressions data with optional breakdown. For Instagram, uses 'reach' as the primary metric.
918
+ """
919
+ params: Dict[str, Any] = {}
920
+ if period:
921
+ params["period"] = period
922
+ if start_date:
923
+ params["start_date"] = start_date
924
+ if end_date:
925
+ params["end_date"] = end_date
926
+ if date:
927
+ params["date"] = date
928
+ if platforms:
929
+ params["platform"] = ",".join(platforms)
930
+ if breakdown:
931
+ params["breakdown"] = "true"
932
+ if metrics:
933
+ params["metrics"] = ",".join(metrics)
934
+ return self._request(f"/uploadposts/total-impressions/{profile_username}", "GET", params=params if params else None)
935
+
936
+ def get_post_analytics(self, request_id: str) -> Dict:
937
+ """
938
+ Get analytics for a specific post across all platforms it was published to.
939
+
940
+ Args:
941
+ request_id: The request_id from the upload.
942
+
943
+ Returns:
944
+ Post analytics with per-platform metrics, profile snapshots, and post-level metrics.
945
+ """
946
+ return self._request(f"/uploadposts/post-analytics/{request_id}", "GET")
947
+
948
+ def get_post_analytics_by_platform_id(self, platform_post_id: str, platform: str, user: str) -> Dict:
949
+ """
950
+ Get analytics for any post (including organic posts) using its native platform ID.
951
+
952
+ Args:
953
+ platform_post_id: The native post ID on the platform (e.g., Instagram media ID).
954
+ platform: The platform to query (instagram, youtube, tiktok, facebook, linkedin, x, threads, pinterest, reddit).
955
+ user: The profile_username that owns the social account.
956
+
957
+ Returns:
958
+ Post analytics with live per-post metrics from the platform API.
959
+ """
960
+ params = {
961
+ "platform_post_id": platform_post_id,
962
+ "platform": platform,
963
+ "user": user,
964
+ }
965
+ return self._request("/uploadposts/post-analytics", "GET", params=params)
966
+
967
+ def get_platform_metrics(self) -> Dict:
968
+ """
969
+ Get available metrics configuration for all supported platforms.
970
+
971
+ Returns:
972
+ Platform metrics config with primary fields, available metrics, and labels.
973
+ """
974
+ return self._request("/uploadposts/platform-metrics", "GET")
975
+
779
976
  # ==================== Scheduled Posts ====================
780
977
 
781
978
  def list_scheduled(self) -> Dict:
@@ -864,7 +1061,11 @@ class UploadPostClient:
864
1061
  redirect_url: Optional[str] = None,
865
1062
  logo_image: Optional[str] = None,
866
1063
  redirect_button_text: Optional[str] = None,
867
- platforms: Optional[List[str]] = None
1064
+ platforms: Optional[List[str]] = None,
1065
+ show_calendar: Optional[bool] = None,
1066
+ readonly_calendar: Optional[bool] = None,
1067
+ connect_title: Optional[str] = None,
1068
+ connect_description: Optional[str] = None
868
1069
  ) -> Dict:
869
1070
  """
870
1071
  Generate a JWT for platform integration.
@@ -876,6 +1077,10 @@ class UploadPostClient:
876
1077
  logo_image: Logo image URL for the linking page.
877
1078
  redirect_button_text: Text for redirect button.
878
1079
  platforms: Platforms to show for connection.
1080
+ show_calendar: Whether to show the calendar view.
1081
+ readonly_calendar: Show only a read-only calendar (no editing, no account connection).
1082
+ connect_title: Custom title for the connection page.
1083
+ connect_description: Custom description for the connection page.
879
1084
 
880
1085
  Returns:
881
1086
  JWT and connection URL.
@@ -889,6 +1094,14 @@ class UploadPostClient:
889
1094
  body["redirect_button_text"] = redirect_button_text
890
1095
  if platforms:
891
1096
  body["platforms"] = platforms
1097
+ if show_calendar is not None:
1098
+ body["show_calendar"] = show_calendar
1099
+ if readonly_calendar is not None:
1100
+ body["readonly_calendar"] = readonly_calendar
1101
+ if connect_title:
1102
+ body["connect_title"] = connect_title
1103
+ if connect_description:
1104
+ body["connect_description"] = connect_description
892
1105
  return self._request("/uploadposts/users/generate-jwt", "POST", json_data=body)
893
1106
 
894
1107
  def validate_jwt(self, jwt: str) -> Dict:
@@ -903,6 +1116,66 @@ class UploadPostClient:
903
1116
  """
904
1117
  return self._request("/uploadposts/users/validate-jwt", "POST", json_data={"jwt": jwt})
905
1118
 
1119
+ def get_user_preferences(self) -> Dict:
1120
+ """
1121
+ Get user preferences (including calendar settings).
1122
+
1123
+ Returns:
1124
+ User preferences including week_start_day.
1125
+ """
1126
+ return self._request("/uploadposts/users/preferences", "GET")
1127
+
1128
+ def update_user_preferences(
1129
+ self,
1130
+ week_start_day: Optional[int] = None
1131
+ ) -> Dict:
1132
+ """
1133
+ Update user preferences (including calendar settings).
1134
+
1135
+ Args:
1136
+ week_start_day: Week start day (0=Sunday, 1=Monday).
1137
+
1138
+ Returns:
1139
+ Updated preferences.
1140
+ """
1141
+ body: Dict[str, Any] = {}
1142
+ if week_start_day is not None:
1143
+ body["week_start_day"] = week_start_day
1144
+ return self._request("/uploadposts/users/preferences", "POST", json_data=body)
1145
+
1146
+ def get_notification_config(self) -> Dict:
1147
+ """
1148
+ Get notification configuration (including webhook settings).
1149
+
1150
+ Returns:
1151
+ Notification config including webhook_events and webhook_url.
1152
+ """
1153
+ return self._request("/uploadposts/notification-config", "GET")
1154
+
1155
+ def update_notification_config(
1156
+ self,
1157
+ webhook_events: Optional[List[str]] = None,
1158
+ webhook_url: Optional[str] = None
1159
+ ) -> Dict:
1160
+ """
1161
+ Update notification configuration (including webhook settings).
1162
+
1163
+ Args:
1164
+ webhook_events: Webhook event types to subscribe to
1165
+ (upload_completed, social_account_connected,
1166
+ social_account_disconnected, social_account_reauth_required).
1167
+ webhook_url: Webhook URL for notifications.
1168
+
1169
+ Returns:
1170
+ Updated notification config.
1171
+ """
1172
+ body: Dict[str, Any] = {}
1173
+ if webhook_events:
1174
+ body["webhook_events"] = webhook_events
1175
+ if webhook_url:
1176
+ body["webhook_url"] = webhook_url
1177
+ return self._request("/uploadposts/notification-config", "POST", json_data=body)
1178
+
906
1179
  # ==================== Helper Endpoints ====================
907
1180
 
908
1181
  def get_facebook_pages(self, profile: Optional[str] = None) -> Dict:
@@ -943,3 +1216,108 @@ class UploadPostClient:
943
1216
  """
944
1217
  params = {"profile": profile} if profile else None
945
1218
  return self._request("/uploadposts/pinterest/boards", "GET", params=params)
1219
+
1220
+ # ==================== Instagram Comments ====================
1221
+
1222
+ def get_post_comments(
1223
+ self,
1224
+ user: str,
1225
+ post_id: Optional[str] = None,
1226
+ post_url: Optional[str] = None
1227
+ ) -> Dict:
1228
+ """
1229
+ Get comments on an Instagram post.
1230
+
1231
+ Args:
1232
+ user: Profile username.
1233
+ post_id: Numeric media ID (provide post_id or post_url).
1234
+ post_url: Full Instagram post URL (provide post_id or post_url).
1235
+
1236
+ Returns:
1237
+ Comments data including comment IDs, text, timestamps, and user info.
1238
+ """
1239
+ params = {"platform": "instagram", "user": user}
1240
+ if post_id:
1241
+ params["post_id"] = post_id
1242
+ if post_url:
1243
+ params["post_url"] = post_url
1244
+ return self._request("/uploadposts/comments", "GET", params=params)
1245
+
1246
+ def reply_to_comment(
1247
+ self,
1248
+ user: str,
1249
+ comment_id: str,
1250
+ message: str
1251
+ ) -> Dict:
1252
+ """
1253
+ Send a private reply (DM) to the author of an Instagram comment.
1254
+
1255
+ Args:
1256
+ user: Profile username.
1257
+ comment_id: Comment ID from get_post_comments.
1258
+ message: Reply message text.
1259
+
1260
+ Returns:
1261
+ Reply result with recipient_id and message_id.
1262
+ """
1263
+ return self._request("/uploadposts/comments/reply", "POST", json_data={
1264
+ "platform": "instagram",
1265
+ "user": user,
1266
+ "comment_id": comment_id,
1267
+ "message": message
1268
+ })
1269
+
1270
+ def public_reply_to_comment(
1271
+ self,
1272
+ user: str,
1273
+ comment_id: str,
1274
+ message: str
1275
+ ) -> Dict:
1276
+ """
1277
+ Post a public reply to an Instagram comment (visible under the original comment).
1278
+
1279
+ Args:
1280
+ user: Profile username.
1281
+ comment_id: Comment ID from get_post_comments.
1282
+ message: Reply message text.
1283
+
1284
+ Returns:
1285
+ Reply result with the new comment ID.
1286
+ """
1287
+ return self._request("/uploadposts/comments/public-reply", "POST", json_data={
1288
+ "platform": "instagram",
1289
+ "user": user,
1290
+ "comment_id": comment_id,
1291
+ "message": message
1292
+ })
1293
+
1294
+ # ==================== Google Business ====================
1295
+
1296
+ def get_google_business_locations(self, profile: Optional[str] = None) -> Dict:
1297
+ """
1298
+ Get Google Business Profile locations for a connected account.
1299
+
1300
+ Args:
1301
+ profile: Profile username.
1302
+
1303
+ Returns:
1304
+ List of Google Business locations.
1305
+ """
1306
+ params = {"profile": profile} if profile else None
1307
+ return self._request("/uploadposts/google-business/locations", "GET", params=params)
1308
+
1309
+ def select_google_business_location(self, location_id: str, profile: Optional[str] = None) -> Dict:
1310
+ """
1311
+ Select a specific Google Business Profile location for a profile.
1312
+
1313
+ Args:
1314
+ location_id: The location ID to select (e.g. "accounts/123/locations/456").
1315
+ profile: Profile username.
1316
+
1317
+ Returns:
1318
+ Selection result with google_business_id and display_name.
1319
+ """
1320
+ data = {"location_id": location_id}
1321
+ if profile:
1322
+ data["profile"] = profile
1323
+ return self._request("/uploadposts/google-business/locations/select", "POST", json_data=data)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: upload-post
3
- Version: 2.0.0
3
+ Version: 2.1.1
4
4
  Summary: Cross-platform social media upload for TikTok, Instagram, YouTube, LinkedIn, Facebook, Pinterest, Threads, Reddit, Bluesky, and X (Twitter)
5
5
  Home-page: https://www.upload-post.com/
6
6
  Author: Upload-Post
@@ -162,6 +162,13 @@ status = client.get_status("request_id_from_upload")
162
162
  print(status)
163
163
  ```
164
164
 
165
+ For scheduled or queued posts, check the status using the job_id:
166
+
167
+ ```python
168
+ status = client.get_job_status("job_id_from_scheduled_post")
169
+ print(status)
170
+ ```
171
+
165
172
  ### Get Upload History
166
173
 
167
174
  ```python
@@ -280,7 +287,8 @@ boards = client.get_pinterest_boards("my-profile")
280
287
  ### Facebook
281
288
  - `facebook_page_id` - Facebook Page ID (required)
282
289
  - `video_state` - PUBLISHED, DRAFT
283
- - `facebook_media_type` - REELS, STORIES
290
+ - `facebook_media_type` - REELS, STORIES, or VIDEO (normal page video)
291
+ - `thumbnail_url` - Thumbnail URL for normal page videos (only when `facebook_media_type` is VIDEO)
284
292
  - `facebook_link_url` - URL for text posts
285
293
 
286
294
  ### Pinterest
@@ -303,9 +311,12 @@ boards = client.get_pinterest_boards("my-profile")
303
311
  - `share_with_followers` - Share community post with followers
304
312
  - `card_uri` - Card URI for Twitter Cards
305
313
  - `x_long_text_as_post` - Post long text as single post
314
+ - `x_thread_image_layout` - Comma-separated image layout for thread (e.g. "4,4" or "2,3,1"). Each value 1-4, total must equal image count. Auto-chunks into groups of 4 when >4 images.
306
315
 
307
316
  ### Threads
308
317
  - `threads_long_text_as_post` - Post long text as single post (vs thread)
318
+ - `threads_thread_media_layout` - Comma-separated list of how many media items to include in each Threads post (e.g. "5,5" or "3,4,3"). Each value 1-10, total must equal media count. Auto-chunks into groups of 10 when >10 items.
319
+ - `threads_topic_tag` - Topic tag for the Threads post (1-50 characters, no periods or ampersands). One tag per post. Helps increase reach.
309
320
 
310
321
  ### Reddit
311
322
  - `subreddit` - Subreddit name (without r/)
@@ -325,6 +336,7 @@ These options work across all upload methods:
325
336
  | `scheduled_date` | ISO date for scheduling |
326
337
  | `timezone` | Timezone for scheduled date |
327
338
  | `add_to_queue` | Add to posting queue |
339
+ | `max_posts_per_slot` | Max posts per queue slot (overrides profile setting) |
328
340
  | `async_upload` | Process asynchronously (default: True) |
329
341
 
330
342
  ## Error Handling
@@ -350,3 +362,5 @@ except UploadPostError as e:
350
362
  ## License
351
363
 
352
364
  MIT
365
+
366
+ <!-- deployed 2026-03-16 17:49 UTC -->
File without changes