upload-post 2.8.1 → 2.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (4) hide show
  1. package/README.md +193 -2
  2. package/index.d.ts +318 -8
  3. package/index.js +219 -25
  4. package/package.json +5 -2
package/README.md CHANGED
@@ -39,6 +39,38 @@ console.log(response);
39
39
  - ✅ **Analytics** - Get engagement metrics
40
40
  - ✅ **Full TypeScript Support**
41
41
 
42
+ ## Using this from an AI agent? Use the MCP server instead
43
+
44
+ If you are wiring Upload-Post into ChatGPT, Claude, Cursor, Claude Code or any
45
+ other MCP-compatible agent, you do not need to write a client on top of this
46
+ SDK. The official **[Model Context Protocol server](https://github.com/Upload-Post/upload-post-mcp)**
47
+ already wraps the whole API as 50 tools the agent can call directly.
48
+
49
+ ```jsonc
50
+ // Hosted (OAuth or API key) — nothing to install
51
+ {
52
+ "mcpServers": {
53
+ "upload-post": { "url": "https://mcp.upload-post.com/mcp" }
54
+ }
55
+ }
56
+ ```
57
+
58
+ ```jsonc
59
+ // Local stdio, built on this SDK
60
+ {
61
+ "mcpServers": {
62
+ "upload-post": {
63
+ "command": "npx",
64
+ "args": ["-y", "@upload-post/mcp"],
65
+ "env": { "UPLOAD_POST_API_KEY": "YOUR_API_KEY" }
66
+ }
67
+ }
68
+ }
69
+ ```
70
+
71
+ See the [MCP integration guide](https://docs.upload-post.com/guides/mcp-server-integration).
72
+ Keep using this SDK when you are writing your own application code.
73
+
42
74
  ## API Reference
43
75
 
44
76
  ### Upload Video
@@ -170,9 +202,16 @@ const jwt = await client.generateJwt('my-profile', {
170
202
  redirectUrl: 'https://yourapp.com/callback',
171
203
  platforms: ['tiktok', 'instagram'],
172
204
  // Optional: force the connection page language for this profile.
173
- // Supported: 'en' | 'es' | 'de' | 'fr' | 'pt'. When omitted, the page
174
- // auto-detects the visitor's browser language and falls back to English.
205
+ // Supported: 'en' | 'es' | 'de' | 'fr' | 'pt' | 'pl' | 'tr'. When omitted, the
206
+ // page auto-detects the visitor's browser language and falls back to English.
175
207
  language: 'es',
208
+ // Optional: override individual connection-page strings. Flat object of i18n
209
+ // dot-path keys to strings. Max 100 entries, keys ^[a-zA-Z0-9_.]+$, values
210
+ // up to 300 chars. Echoed back in the `profile` object of validateJwt.
211
+ uiLabels: {
212
+ 'connect.title': 'Link your accounts',
213
+ 'connect.subtitle': 'Publish everywhere from one place',
214
+ },
176
215
  });
177
216
  ```
178
217
 
@@ -183,6 +222,34 @@ const analytics = await client.getAnalytics('my-profile', {
183
222
  platforms: ['instagram', 'tiktok'],
184
223
  });
185
224
  console.log(analytics);
225
+
226
+ // Instagram returns two audience breakdowns with the same shape
227
+ // ({ age, gender, country, city }):
228
+ console.log(analytics.analytics.instagram.follower_demographics);
229
+ console.log(analytics.analytics.instagram.engaged_audience_demographics);
230
+ ```
231
+
232
+ ### Cached Post Analytics
233
+
234
+ Replays per-post metrics already fetched, instead of calling the platforms again. Only contains posts previously fetched through a live per-post endpoint; there is no background refresh, so captured_at is the last time that post was read live. Not subject to the live
235
+ calls, so it is not subject to the live post-analytics rate limit
236
+ (100 requests / 5 minutes). Use it to page through a profile's post history.
237
+
238
+ ```javascript
239
+ let cursor;
240
+ do {
241
+ const page = await client.getCachedPostAnalytics('my-profile', {
242
+ platform: 'youtube', // optional: instagram, tiktok, youtube, facebook, linkedin, threads, pinterest, reddit
243
+ limit: 50, // default 50, max 200
244
+ since: '2026-06-01', // defaults to 30 days ago
245
+ until: '2026-07-01', // defaults to today
246
+ cursor,
247
+ });
248
+ for (const post of page.posts) {
249
+ console.log(post.platform, post.post_id, post.metrics);
250
+ }
251
+ cursor = page.next_cursor;
252
+ } while (cursor);
186
253
  ```
187
254
 
188
255
  ### Get Media
@@ -201,6 +268,22 @@ await client.getMedia('linkedin', 'my-profile', { pageUrn: 'me' });
201
268
  await client.getMedia('linkedin', 'my-profile', { pageUrn: '12345' });
202
269
  ```
203
270
 
271
+ The response carries a `pagination` object — `{ limit, next_cursor, has_more }`,
272
+ with `next_cursor: null` and `has_more: false` on the last page:
273
+
274
+ ```javascript
275
+ let cursor;
276
+ do {
277
+ const page = await client.getMedia('instagram', 'my-profile', { limit: 50, cursor });
278
+ console.log(page.media.length);
279
+ cursor = page.pagination.next_cursor;
280
+ } while (cursor);
281
+ ```
282
+
283
+ `limit` defaults to 25 and is clamped to 1-100, with per-platform caps of 20 for
284
+ TikTok and 50 for YouTube. **LinkedIn, Discord and Telegram do not support
285
+ cursors** — they accept `limit` only, and passing a `cursor` returns HTTP 400.
286
+
204
287
  ### Helper Methods
205
288
 
206
289
  ```javascript
@@ -212,8 +295,80 @@ const liPages = await client.getLinkedinPages('my-profile');
212
295
 
213
296
  // Get Pinterest boards for a profile
214
297
  const boards = await client.getPinterestBoards('my-profile');
298
+
299
+ // TikTok: trending Commercial Music Library tracks
300
+ const music = await client.getTiktokTrendingMusic('my-profile', {
301
+ genre: 'POP',
302
+ countryCode: 'ES',
303
+ dateRange: '7DAY', // 1DAY, 7DAY, 30DAY, 90DAY
304
+ });
305
+
306
+ // TikTok: find a track by song or artist
307
+ const found = await client.searchTiktokMusic('my-profile', {
308
+ q: 'bad bunny',
309
+ countryCode: 'ES',
310
+ });
311
+
312
+ // TikTok: search locations to tag
313
+ const locations = await client.getTiktokLocations('my-profile', 'Madrid');
215
314
  ```
216
315
 
316
+ ## TikTok music, location, cover and drafts
317
+
318
+ > **Capabilities.** These options are available on connections that declare the
319
+ > matching capability — `music`, `location`, `cover_image`, `draft` — in the
320
+ > `capabilities` array of the TikTok account returned by `listUsers()`
321
+ > (other values you may see there: `cover_timestamp`, `photo_privacy`,
322
+ > `video_privacy`, `inbox_fallback`, `profile_analytics`)
323
+ > (`GET /api/uploadposts/users`). If your connection does not have it, the field
324
+ > is ignored, the post still publishes, and the response includes a per-field
325
+ > `warnings` entry — reconnect the TikTok account to enable it.
326
+
327
+ ```javascript
328
+ // 1. Pick a track and a place
329
+ const { tracks } = await client.getTiktokTrendingMusic('my-profile', { countryCode: 'ES' });
330
+ // ...or find one by name. TikTok has no music search endpoint, so this searches
331
+ // the trending charts Upload-Post caches, not TikTok's whole catalogue.
332
+ // const { tracks } = await client.searchTiktokMusic('my-profile', { q: 'bossa', countryCode: 'ES' });
333
+ const { locations } = await client.getTiktokLocations('my-profile', 'Madrid');
334
+
335
+ // 2. Publish with them
336
+ await client.upload('./video.mp4', {
337
+ title: 'Shot in Madrid',
338
+ user: 'my-profile',
339
+ platforms: ['tiktok'],
340
+
341
+ tiktokMusicId: tracks[0].id,
342
+ tiktokMusicVolume: 70, // 0-100, defaults to 50 when music is set
343
+ tiktokMusicStart: 0, // ms
344
+ tiktokMusicEnd: 15000, // ms
345
+ tiktokOriginalSoundVolume: 30, // 0-100, defaults to 50 so the original audio is not muted
346
+
347
+ tiktokLocationId: locations[0].location_id,
348
+ tiktokLocationName: locations[0].location_name, // required together with the id
349
+
350
+ tiktokCoverImageUrl: 'https://example.com/cover.jpg',
351
+ tiktokIsAiGenerated: false,
352
+ tiktokUploadToDraft: false, // true sends it to drafts and ignores the rest
353
+ });
354
+ ```
355
+
356
+ ### Options
357
+
358
+ | Option | Type | Notes |
359
+ | --- | --- | --- |
360
+ | `tiktokMusicId` | string | The track `id` from `getTiktokTrendingMusic()` or `searchTiktokMusic()` (not `commercial_music_id`) |
361
+ | `tiktokMusicVolume` | number | 0-100. Defaults to 50 when music is set |
362
+ | `tiktokMusicStart` | number | Music start offset in ms |
363
+ | `tiktokMusicEnd` | number | Music end offset in ms |
364
+ | `tiktokOriginalSoundVolume` | number | 0-100. Defaults to 50 when music is set, so the original audio is not muted |
365
+ | `tiktokLocationId` | string | `location_id` from `getTiktokLocations()` |
366
+ | `tiktokLocationName` | string | Required whenever `tiktokLocationId` is set |
367
+ | `tiktokCoverImageUrl` | string | Custom cover image URL |
368
+ | `tiktokIsAiGenerated` | boolean | AI-generated content disclosure |
369
+ | `tiktokUploadToDraft` | boolean | Publish to drafts; TikTok ignores the rest of the post settings |
370
+ | `tiktokPhotoCoverIndex` | number | Cover photo index for photo posts (0-based) |
371
+
217
372
  ## Platform-Specific Options
218
373
 
219
374
  ### TikTok (Video)
@@ -227,10 +382,29 @@ const boards = await client.getPinterestBoards('my-profile');
227
382
  - `brandContentToggle` - Branded content toggle
228
383
  - `brandOrganicToggle` - Brand organic toggle
229
384
 
385
+ > **Which privacy levels are available is decided by TikTok per account.** A
386
+ > private account, for example, is offered `FOLLOWER_OF_CREATOR`,
387
+ > `MUTUAL_FOLLOW_FRIENDS` and `SELF_ONLY` and has no `PUBLIC_TO_EVERYONE`.
388
+ > Asking for one the account does not have fails with
389
+ > `error_code: "tiktok_privacy_unavailable"` and an error listing the ones it
390
+ > does have. Omit `tiktokPrivacyLevel` on video and TikTok keeps the account's
391
+ > own default; on **photo** posts it defaults to `PUBLIC_TO_EVERYONE`.
392
+
393
+ See [TikTok music, location, cover and drafts](#tiktok-music-location-cover-and-drafts)
394
+ for those options.
395
+
230
396
  ### TikTok (Photos)
231
397
  - `tiktokAutoAddMusic` - Auto add music
232
398
  - `tiktokPhotoCoverIndex` - Index of photo for cover (0-based)
233
399
  - `tiktokDisableComment` - Disable comments
400
+ - `tiktokPrivacyLevel` - PUBLIC_TO_EVERYONE, MUTUAL_FOLLOW_FRIENDS, FOLLOWER_OF_CREATOR, SELF_ONLY (same field and same per-account limits as video)
401
+ - `tiktokMusicId` - Commercial Music Library track id (see `getTiktokTrendingMusic`)
402
+ - `tiktokLocationId` / `tiktokLocationName` - Location tag, both required together
403
+ - `tiktokIsAiGenerated` - AI-generated content disclosure
404
+
405
+ > TikTok's photo contract takes the music track id alone: `tiktokMusicVolume`,
406
+ > `tiktokMusicStart`, `tiktokMusicEnd`, `tiktokOriginalSoundVolume`,
407
+ > `tiktokCoverImageUrl` and `tiktokUploadToDraft` are video-only.
234
408
 
235
409
  ### Instagram
236
410
  - `instagramMediaType` - REELS, STORIES, IMAGE
@@ -350,6 +524,22 @@ await client.uploadText({
350
524
 
351
525
  Also available: `gbpTopicType: 'EVENT'` with `gbpEventTitle` / `gbpEventStartDate` / `gbpEventStartTime` / `gbpEventEndDate` / `gbpEventEndTime`, a call-to-action via `gbpCtaType` + `gbpCtaUrl`, and `gbpMediaUrl` / `gbpMediaFormat`.
352
526
 
527
+ ### Gallery photos
528
+
529
+ Set `gbpPostType` to publish straight into the location's photo gallery instead of creating a Local Post:
530
+
531
+ ```javascript
532
+ await client.uploadPhotos(['storefront.jpg'], {
533
+ user: 'myprofile',
534
+ platforms: ['google_business'],
535
+ gbpLocationId: locations[0].name,
536
+ gbpPostType: 'GALLERY', // MEDIA | PHOTO | GALLERY
537
+ gbpMediaCategory: 'EXTERIOR', // defaults to ADDITIONAL
538
+ });
539
+ ```
540
+
541
+ Omitting `gbpPostType` (or sending any other value) keeps the existing Local Post behaviour. `gbpMediaCategory` accepts `COVER`, `PROFILE`, `LOGO`, `EXTERIOR`, `INTERIOR`, `PRODUCT`, `AT_WORK`, `FOOD_AND_DRINK`, `MENU`, `COMMON_AREA`, `ROOMS`, `TEAMS` and `ADDITIONAL`.
542
+
353
543
  ## Retrying an upload safely
354
544
 
355
545
  An upload that times out may still have been accepted by the API. Retrying it without an idempotency key publishes the post a second time.
@@ -407,6 +597,7 @@ try {
407
597
  - [Upload-Post Website](https://www.upload-post.com)
408
598
  - [API Documentation](https://docs.upload-post.com)
409
599
  - [Dashboard](https://app.upload-post.com)
600
+ - [MCP Server](https://www.upload-post.com/mcp) — connect ChatGPT, Claude, Cursor and any AI agent ([source](https://github.com/Upload-Post/upload-post-mcp), [npm](https://www.npmjs.com/package/@upload-post/mcp))
410
601
 
411
602
  ## License
412
603
 
package/index.d.ts CHANGED
@@ -114,7 +114,12 @@ declare module 'upload-post' {
114
114
  // ==================== TikTok Options ====================
115
115
 
116
116
  export interface TikTokVideoOptions {
117
- /** Privacy setting */
117
+ /**
118
+ * Privacy setting. TikTok decides per account which levels are available (a
119
+ * private account has no `PUBLIC_TO_EVERYONE`); asking for another one fails
120
+ * with `error_code: "tiktok_privacy_unavailable"` listing the allowed ones.
121
+ * Omit it to keep the account's own default.
122
+ */
118
123
  tiktokPrivacyLevel?: TikTokPrivacyLevel;
119
124
  /** Disable duet */
120
125
  tiktokDisableDuet?: boolean;
@@ -132,6 +137,35 @@ declare module 'upload-post' {
132
137
  brandContentToggle?: boolean;
133
138
  /** Brand organic toggle */
134
139
  brandOrganicToggle?: boolean;
140
+
141
+ // ---- Music, location, cover and draft ----
142
+ // Available on connections that declare the matching capability (`music`,
143
+ // `location`, `cover_image`, `draft`) — see `capabilities` on the TikTok
144
+ // account returned by listUsers() / GET /api/uploadposts/users. If your
145
+ // connection does not have it, the field is ignored, the post still
146
+ // publishes, and the response includes a per-field `warnings` entry —
147
+ // reconnect the TikTok account to enable it.
148
+
149
+ /** Commercial Music Library track id (see getTiktokTrendingMusic) */
150
+ tiktokMusicId?: string;
151
+ /** Music volume, 0-100. Defaults to 50 when music is set */
152
+ tiktokMusicVolume?: number;
153
+ /** Music start offset in ms */
154
+ tiktokMusicStart?: number;
155
+ /** Music end offset in ms */
156
+ tiktokMusicEnd?: number;
157
+ /** Original video audio volume, 0-100. Defaults to 50 when music is set, so the original audio is not muted */
158
+ tiktokOriginalSoundVolume?: number;
159
+ /** Location id to tag (see getTiktokLocations) */
160
+ tiktokLocationId?: string;
161
+ /** Location name. Required whenever tiktokLocationId is set */
162
+ tiktokLocationName?: string;
163
+ /** Custom cover image URL */
164
+ tiktokCoverImageUrl?: string;
165
+ /** AI-generated content disclosure */
166
+ tiktokIsAiGenerated?: boolean;
167
+ /** Publish to drafts. When true TikTok ignores the rest of the post settings */
168
+ tiktokUploadToDraft?: boolean;
135
169
  }
136
170
 
137
171
  export interface TikTokPhotoOptions {
@@ -139,16 +173,39 @@ declare module 'upload-post' {
139
173
  tiktokAutoAddMusic?: boolean;
140
174
  /** Disable comments */
141
175
  tiktokDisableComment?: boolean;
142
- /** Index of photo for cover (0-based) */
176
+ /** Index of photo for cover (0-based). Sent as `photo_cover_index` */
143
177
  tiktokPhotoCoverIndex?: number;
144
- /** Privacy level, e.g. PUBLIC_TO_EVERYONE, SELF_ONLY, MUTUAL_FOLLOW_FRIENDS */
145
- tiktokPrivacyLevel?: string;
178
+ /**
179
+ * Privacy setting. TikTok REQUIRES one on photo posts, so it defaults to
180
+ * `PUBLIC_TO_EVERYONE`. Which values the account may use is decided by TikTok
181
+ * (a private account has no `PUBLIC_TO_EVERYONE`); asking for another one
182
+ * fails with `error_code: "tiktok_privacy_unavailable"` listing the allowed
183
+ * ones.
184
+ */
185
+ tiktokPrivacyLevel?: TikTokPrivacyLevel;
146
186
  /** Post mode, e.g. DIRECT_POST or MEDIA_UPLOAD (inbox) */
147
187
  tiktokPostMode?: string;
148
188
  /** Branded content toggle */
149
189
  brandContentToggle?: boolean;
150
190
  /** Brand organic toggle */
151
191
  brandOrganicToggle?: boolean;
192
+
193
+ // ---- Music, location and AI disclosure ----
194
+ // Photo posts accept the track id, the location pair and the AI disclosure —
195
+ // not the volume/trim, cover-image or draft fields, which are video-only.
196
+ // Available on connections that declare the matching capability (`music`,
197
+ // `location`) — see `capabilities` on the TikTok account returned by
198
+ // listUsers(). Without it the field is ignored, the post still publishes, and
199
+ // the response includes a per-field `warnings` entry.
200
+
201
+ /** Commercial Music Library track id (see getTiktokTrendingMusic) */
202
+ tiktokMusicId?: string;
203
+ /** Location id to tag (see getTiktokLocations) */
204
+ tiktokLocationId?: string;
205
+ /** Location name. Required whenever tiktokLocationId is set */
206
+ tiktokLocationName?: string;
207
+ /** AI-generated content disclosure */
208
+ tiktokIsAiGenerated?: boolean;
152
209
  }
153
210
 
154
211
  // ==================== Instagram Options ====================
@@ -358,9 +415,28 @@ declare module 'upload-post' {
358
415
 
359
416
  // ==================== Combined Upload Options ====================
360
417
 
418
+ export type GoogleBusinessMediaCategory =
419
+ | 'COVER'
420
+ | 'PROFILE'
421
+ | 'LOGO'
422
+ | 'EXTERIOR'
423
+ | 'INTERIOR'
424
+ | 'PRODUCT'
425
+ | 'AT_WORK'
426
+ | 'FOOD_AND_DRINK'
427
+ | 'MENU'
428
+ | 'COMMON_AREA'
429
+ | 'ROOMS'
430
+ | 'TEAMS'
431
+ | 'ADDITIONAL';
432
+
361
433
  export interface GoogleBusinessOptions {
362
434
  /** Google Business location id, e.g. "accounts/123/locations/456". Required when the account has more than one location; the API auto-selects only for single-location accounts. List them with getGoogleBusinessLocations(). */
363
435
  gbpLocationId?: string;
436
+ /** Publish to the location's gallery instead of creating a Local Post. Omitting it (or sending any other value) keeps the Local Post behaviour. */
437
+ gbpPostType?: 'MEDIA' | 'PHOTO' | 'GALLERY';
438
+ /** Gallery category for the uploaded photo. Only used with gbpPostType. Defaults to ADDITIONAL. */
439
+ gbpMediaCategory?: GoogleBusinessMediaCategory;
364
440
  /** Post type. Defaults to STANDARD. */
365
441
  gbpTopicType?: 'STANDARD' | 'EVENT' | 'OFFER';
366
442
  /** Media URL attached to the post */
@@ -496,9 +572,31 @@ declare module 'upload-post' {
496
572
  [key: string]: any;
497
573
  }
498
574
 
575
+ /**
576
+ * Instagram audience breakdown. Each key maps a bucket (age range, gender,
577
+ * country code, city name) to a follower/engager count.
578
+ */
579
+ export interface DemographicsBreakdown {
580
+ age?: Record<string, number>;
581
+ gender?: Record<string, number>;
582
+ country?: Record<string, number>;
583
+ city?: Record<string, number>;
584
+ }
585
+
586
+ export interface InstagramAnalytics {
587
+ /** Breakdown of the account's followers. */
588
+ follower_demographics?: DemographicsBreakdown;
589
+ /** Breakdown of the accounts that engaged with the profile's content. Same shape as follower_demographics. */
590
+ engaged_audience_demographics?: DemographicsBreakdown;
591
+ [key: string]: any;
592
+ }
593
+
499
594
  export interface AnalyticsResponse {
500
595
  success: boolean;
501
- analytics?: any;
596
+ analytics?: {
597
+ instagram?: InstagramAnalytics;
598
+ [platform: string]: any;
599
+ };
502
600
  [key: string]: any;
503
601
  }
504
602
 
@@ -539,6 +637,80 @@ declare module 'upload-post' {
539
637
  [key: string]: any;
540
638
  }
541
639
 
640
+ /** Trending window accepted by the TikTok Commercial Music Library. */
641
+ export type TikTokMusicDateRange = '1DAY' | '7DAY' | '30DAY' | '90DAY';
642
+
643
+ export interface TikTokMusicTrack {
644
+ /** Pass this as `tiktokMusicId` on an upload. */
645
+ id: string;
646
+ /**
647
+ * TikTok's catalogue id for the same track, returned for reference only.
648
+ * Do NOT send it as `tiktokMusicId`: TikTok rejects it on public posts.
649
+ */
650
+ commercial_music_id?: string;
651
+ title?: string;
652
+ artist?: string;
653
+ /** Track duration in seconds. */
654
+ duration?: number;
655
+ genres?: string[];
656
+ /** Artwork image URL. */
657
+ cover_url?: string;
658
+ /** Audio preview URL. */
659
+ preview_url?: string;
660
+ rank?: number;
661
+ [key: string]: any;
662
+ }
663
+
664
+ export interface TikTokTrendingMusicResponse {
665
+ success: boolean;
666
+ tracks?: TikTokMusicTrack[];
667
+ [key: string]: any;
668
+ }
669
+
670
+ export interface TikTokMusicSearchResponse {
671
+ success: boolean;
672
+ /** Matches, ranked by relevance; same shape as the trending endpoint. */
673
+ tracks?: TikTokMusicTrack[];
674
+ /** How many tracks matched before `limit` was applied. */
675
+ total?: number;
676
+ query?: string;
677
+ /** What the search actually ran against. */
678
+ catalog?: {
679
+ /** Number of tracks in the searched corpus. */
680
+ tracks_indexed?: number;
681
+ /** Genre charts currently loaded for this country and period. */
682
+ genres_indexed?: string[];
683
+ /** False when this request had to load a chart from TikTok. */
684
+ cached?: boolean;
685
+ };
686
+ [key: string]: any;
687
+ }
688
+
689
+ export interface TikTokLocation {
690
+ location_id: string;
691
+ location_name?: string;
692
+ location_address?: string;
693
+ [key: string]: any;
694
+ }
695
+
696
+ export interface TikTokLocationsResponse {
697
+ success: boolean;
698
+ locations?: TikTokLocation[];
699
+ [key: string]: any;
700
+ }
701
+
702
+ export interface TikTokPublishingSettingsResponse {
703
+ success: boolean;
704
+ /** The privacy levels THIS account may use — a subset of the four. */
705
+ privacy_level_options?: TikTokPrivacyLevel[];
706
+ /** Longest video the account can publish, in seconds. */
707
+ max_video_post_duration_sec?: number;
708
+ comment_disabled?: boolean;
709
+ duet_disabled?: boolean;
710
+ stitch_disabled?: boolean;
711
+ [key: string]: any;
712
+ }
713
+
542
714
  // ==================== Client Class ====================
543
715
 
544
716
  /**
@@ -654,6 +826,48 @@ declare module 'upload-post' {
654
826
  }>;
655
827
  }>;
656
828
 
829
+ /**
830
+ * Replay per-post metrics already fetched, instead of querying
831
+ * the platforms live. Not subject to the live post-analytics rate limit
832
+ * (100 requests / 5 minutes), so it is the right call for paging through a
833
+ * profile's whole post history.
834
+ *
835
+ * @param user - Profile username
836
+ * @param options - Query options
837
+ */
838
+ getCachedPostAnalytics(user: string, options?: {
839
+ platform?: 'instagram' | 'tiktok' | 'youtube' | 'facebook' | 'linkedin' | 'threads' | 'pinterest' | 'reddit';
840
+ /** Posts per page. Default 50, max 200. */
841
+ limit?: number;
842
+ /** Opaque cursor from a previous response's next_cursor. */
843
+ cursor?: string;
844
+ /** Start date, YYYY-MM-DD. Defaults to 30 days ago. */
845
+ since?: string;
846
+ /** End date, YYYY-MM-DD. Defaults to today. */
847
+ until?: string;
848
+ }): Promise<{
849
+ success: boolean;
850
+ profile_username: string;
851
+ platform: string | null;
852
+ since: string;
853
+ until: string;
854
+ source: 'snapshot_cache';
855
+ posts: Array<{
856
+ post_id: string;
857
+ platform: string;
858
+ profile_username: string;
859
+ date: string;
860
+ captured_at: string;
861
+ metrics: Record<string, number>;
862
+ post_url?: string | null;
863
+ media_type?: string | null;
864
+ upload_timestamp?: string | null;
865
+ }>;
866
+ limit: number;
867
+ next_cursor: string | null;
868
+ has_more: boolean;
869
+ }>;
870
+
657
871
  /**
658
872
  * Get available metrics configuration for all supported platforms
659
873
  */
@@ -667,11 +881,13 @@ declare module 'upload-post' {
667
881
  * Get recent media from a connected social account.
668
882
  *
669
883
  * @param options.pageUrn - LinkedIn only. Numeric org ID, full org URN, or "me" to force the personal profile.
884
+ * @param options.limit - Items per page. Default 25, clamped to 1-100. Per-platform caps: TikTok 20, YouTube 50, everything else 100.
885
+ * @param options.cursor - Opaque cursor from a previous response's `pagination.next_cursor`. LinkedIn, Discord and Telegram do not support cursors: passing one there returns HTTP 400.
670
886
  */
671
887
  getMedia(
672
888
  platform: 'instagram' | 'tiktok' | 'youtube' | 'linkedin' | 'facebook' | 'x' | 'threads' | 'pinterest' | 'bluesky' | 'reddit' | string,
673
889
  user: string,
674
- options?: { pageUrn?: string }
890
+ options?: { pageUrn?: string; limit?: number; cursor?: string }
675
891
  ): Promise<{
676
892
  success: boolean;
677
893
  media: Array<{
@@ -683,6 +899,12 @@ declare module 'upload-post' {
683
899
  timestamp: string | null;
684
900
  thumbnail_url: string | null;
685
901
  }>;
902
+ pagination?: {
903
+ limit: number;
904
+ /** null on the last page. */
905
+ next_cursor: string | null;
906
+ has_more: boolean;
907
+ };
686
908
  }>;
687
909
 
688
910
  // Scheduled Posts
@@ -736,14 +958,32 @@ declare module 'upload-post' {
736
958
  readonlyCalendar?: boolean;
737
959
  connectTitle?: string;
738
960
  connectDescription?: string;
739
- language?: 'en' | 'es' | 'de' | 'fr' | 'pt';
961
+ language?: 'en' | 'es' | 'de' | 'fr' | 'pt' | 'pl' | 'tr';
962
+ /**
963
+ * Flat map of i18n dot-path keys to replacement strings, e.g.
964
+ * `{ 'connect.title': 'Link your accounts' }`. Max 100 entries; keys must
965
+ * match `^[a-zA-Z0-9_.]+$` and values are strings of up to 300 characters.
966
+ * Returned back in the `profile` object of validateJwt.
967
+ */
968
+ uiLabels?: Record<string, string>;
740
969
  }): Promise<JwtResponse>;
741
970
 
742
971
  /**
743
972
  * Validate a JWT token
744
973
  * @param jwt - JWT token to validate
745
974
  */
746
- validateJwt(jwt: string): Promise<{ success: boolean; valid?: boolean }>;
975
+ validateJwt(jwt: string): Promise<{
976
+ success: boolean;
977
+ valid?: boolean;
978
+ profile?: {
979
+ username?: string;
980
+ language?: 'en' | 'es' | 'de' | 'fr' | 'pt' | 'pl' | 'tr';
981
+ /** The ui_labels sent to generateJwt, echoed back for the connection page. */
982
+ ui_labels?: Record<string, string>;
983
+ [key: string]: any;
984
+ };
985
+ [key: string]: any;
986
+ }>;
747
987
 
748
988
  /**
749
989
  * Get user preferences (including calendar settings)
@@ -939,6 +1179,76 @@ declare module 'upload-post' {
939
1179
  * @param profile - Profile username
940
1180
  */
941
1181
  getGoogleBusinessLocations(profile?: string): Promise<Record<string, unknown>>;
1182
+
1183
+ /**
1184
+ * Get trending tracks from the TikTok Commercial Music Library.
1185
+ *
1186
+ * Available on connections that declare the `music` capability (see
1187
+ * `capabilities` on the TikTok account returned by listUsers() /
1188
+ * GET /api/uploadposts/users). Pass the returned `id` as `tiktokMusicId`
1189
+ * on an upload (NOT `commercial_music_id` — TikTok rejects that one on
1190
+ * public posts).
1191
+ *
1192
+ * @param profile - Profile username
1193
+ * @param options - Query options
1194
+ */
1195
+ getTiktokTrendingMusic(
1196
+ profile: string,
1197
+ options?: {
1198
+ genre?: string;
1199
+ countryCode?: string;
1200
+ dateRange?: TikTokMusicDateRange;
1201
+ }
1202
+ ): Promise<TikTokTrendingMusicResponse>;
1203
+
1204
+ /**
1205
+ * Search the TikTok Commercial Music Library by song title or artist.
1206
+ *
1207
+ * TikTok has no music search endpoint, so this searches the trending charts
1208
+ * Upload-Post caches (per genre / country / period), not TikTok's whole
1209
+ * catalogue. Matching is case- and accent-insensitive and every word must
1210
+ * match. Returns the same track objects as getTiktokTrendingMusic().
1211
+ *
1212
+ * @param profile - Profile username
1213
+ * @param options - Query options
1214
+ */
1215
+ searchTiktokMusic(
1216
+ profile: string,
1217
+ options?: {
1218
+ q?: string;
1219
+ genre?: string;
1220
+ countryCode?: string;
1221
+ dateRange?: TikTokMusicDateRange;
1222
+ limit?: number;
1223
+ }
1224
+ ): Promise<TikTokMusicSearchResponse>;
1225
+
1226
+ /**
1227
+ * Search TikTok locations (places) to tag on a post.
1228
+ *
1229
+ * Available on connections that declare the `location` capability (see
1230
+ * `capabilities` on the TikTok account returned by listUsers() /
1231
+ * GET /api/uploadposts/users). TikTok requires both the id and the name, so
1232
+ * pass `location_id` as `tiktokLocationId` and `location_name` as
1233
+ * `tiktokLocationName`.
1234
+ *
1235
+ * @param profile - Profile username
1236
+ * @param query - Search query (max 100 characters)
1237
+ */
1238
+ getTiktokLocations(profile: string, query: string): Promise<TikTokLocationsResponse>;
1239
+
1240
+ /**
1241
+ * Get what the connected TikTok account is allowed to publish.
1242
+ *
1243
+ * The point of this call is `privacy_level_options`: TikTok narrows the four
1244
+ * privacy values per account (a private account has no `PUBLIC_TO_EVERYONE`),
1245
+ * and sending one the account does not have fails the upload with
1246
+ * `error_code: "tiktok_privacy_unavailable"`. Ask here to offer only the
1247
+ * values that will work, instead of the full enum.
1248
+ *
1249
+ * @param profile - Profile username
1250
+ */
1251
+ getTiktokPublishingSettings(profile: string): Promise<TikTokPublishingSettingsResponse>;
942
1252
  }
943
1253
 
944
1254
  export default UploadPost;
package/index.js CHANGED
@@ -152,25 +152,54 @@ export class UploadPost {
152
152
  * @private
153
153
  */
154
154
  _addTiktokParams(form, options, isVideo = true) {
155
- if (options.tiktokDisableComment !== undefined) form.append('disable_comment', String(options.tiktokDisableComment));
156
- if (options.brandContentToggle !== undefined) form.append('brand_content_toggle', String(options.brandContentToggle));
157
- if (options.brandOrganicToggle !== undefined) form.append('brand_organic_toggle', String(options.brandOrganicToggle));
158
-
159
- // Shared by TikTok video AND photo uploads: the backend reads privacy_level and
160
- // post_mode for both /upload and /upload_photos. Gating them behind isVideo made
161
- // photo carousels always publish as PUBLIC_TO_EVERYONE / DIRECT_POST.
162
- if (options.tiktokPrivacyLevel) form.append('privacy_level', options.tiktokPrivacyLevel);
163
- if (options.tiktokPostMode) form.append('post_mode', options.tiktokPostMode);
164
-
165
- if (isVideo) {
166
- if (options.tiktokDisableDuet !== undefined) form.append('disable_duet', String(options.tiktokDisableDuet));
167
- if (options.tiktokDisableStitch !== undefined) form.append('disable_stitch', String(options.tiktokDisableStitch));
168
- if (options.tiktokCoverTimestamp !== undefined) form.append('cover_timestamp', options.tiktokCoverTimestamp);
169
- if (options.tiktokIsAigc !== undefined) form.append('is_aigc', String(options.tiktokIsAigc));
170
- } else {
171
- // Photo-specific
172
- if (options.tiktokAutoAddMusic !== undefined) form.append('auto_add_music', String(options.tiktokAutoAddMusic));
173
- if (options.tiktokPhotoCoverIndex !== undefined) form.append('photo_cover_index', options.tiktokPhotoCoverIndex);
155
+ // [form field, option key, "flag" when the value is a boolean/number sent as
156
+ // soon as it is defined, "text" when an empty value means "not set"].
157
+ //
158
+ // The shared block is shared on purpose: the backend reads privacy_level and
159
+ // post_mode on both /upload and /upload_photos, and TikTok's photo endpoint
160
+ // also accepts the music track id, the location pair and is_ai_generated.
161
+ // Gating them behind isVideo made photo carousels always publish as
162
+ // PUBLIC_TO_EVERYONE / DIRECT_POST and unable to carry music or a location.
163
+ //
164
+ // The music/location/cover/draft fields need the matching capability on the
165
+ // connection (see `capabilities` on the TikTok account returned by
166
+ // listUsers() / GET /api/uploadposts/users). Without it the field is ignored,
167
+ // the post still publishes, and the response carries a per-field `warnings`
168
+ // entry.
169
+ const shared = [
170
+ ['disable_comment', 'tiktokDisableComment', 'flag'],
171
+ ['brand_content_toggle', 'brandContentToggle', 'flag'],
172
+ ['brand_organic_toggle', 'brandOrganicToggle', 'flag'],
173
+ ['privacy_level', 'tiktokPrivacyLevel', 'text'],
174
+ ['post_mode', 'tiktokPostMode', 'text'],
175
+ ['tiktok_music_id', 'tiktokMusicId', 'text'],
176
+ ['tiktok_location_id', 'tiktokLocationId', 'text'],
177
+ ['tiktok_location_name', 'tiktokLocationName', 'text'],
178
+ ['tiktok_is_ai_generated', 'tiktokIsAiGenerated', 'flag'],
179
+ ];
180
+ // Video-only: TikTok's photo contract takes the track id alone, with no
181
+ // volume or trim, and has no custom cover or draft switch.
182
+ const videoOnly = [
183
+ ['disable_duet', 'tiktokDisableDuet', 'flag'],
184
+ ['disable_stitch', 'tiktokDisableStitch', 'flag'],
185
+ ['cover_timestamp', 'tiktokCoverTimestamp', 'flag'],
186
+ ['is_aigc', 'tiktokIsAigc', 'flag'],
187
+ ['tiktok_music_volume', 'tiktokMusicVolume', 'flag'],
188
+ ['tiktok_music_start', 'tiktokMusicStart', 'flag'],
189
+ ['tiktok_music_end', 'tiktokMusicEnd', 'flag'],
190
+ ['tiktok_original_sound_volume', 'tiktokOriginalSoundVolume', 'flag'],
191
+ ['tiktok_cover_image_url', 'tiktokCoverImageUrl', 'text'],
192
+ ['tiktok_upload_to_draft', 'tiktokUploadToDraft', 'flag'],
193
+ ];
194
+ const photoOnly = [
195
+ ['auto_add_music', 'tiktokAutoAddMusic', 'flag'],
196
+ ['photo_cover_index', 'tiktokPhotoCoverIndex', 'flag'],
197
+ ];
198
+
199
+ for (const [field, key, kind] of shared.concat(isVideo ? videoOnly : photoOnly)) {
200
+ const value = options[key];
201
+ if (kind === 'flag' ? value === undefined : !value) continue;
202
+ form.append(field, String(value));
174
203
  }
175
204
  }
176
205
 
@@ -348,10 +377,16 @@ export class UploadPost {
348
377
  *
349
378
  * gbpLocationId is required for accounts with more than one location; the API
350
379
  * only auto-selects when exactly one location exists.
380
+ *
381
+ * gbpPostType switches the call from the default Local Post to a gallery
382
+ * photo upload (MEDIA / PHOTO / GALLERY). Any other value, or omitting it,
383
+ * keeps the Local Post behaviour.
351
384
  * @private
352
385
  */
353
386
  _addGoogleBusinessParams(form, options) {
354
387
  if (options.gbpLocationId) form.append('gbp_location_id', options.gbpLocationId);
388
+ if (options.gbpPostType) form.append('gbp_post_type', String(options.gbpPostType).toUpperCase());
389
+ if (options.gbpMediaCategory) form.append('gbp_media_category', String(options.gbpMediaCategory).toUpperCase());
355
390
  if (options.gbpTopicType) form.append('gbp_topic_type', String(options.gbpTopicType).toUpperCase());
356
391
  if (options.gbpMediaUrl) form.append('gbp_media_url', options.gbpMediaUrl);
357
392
  if (options.gbpMediaFormat) form.append('gbp_media_format', String(options.gbpMediaFormat).toUpperCase());
@@ -395,7 +430,7 @@ export class UploadPost {
395
430
  * @param {boolean} [options.asyncUpload=true] - Process upload asynchronously
396
431
  *
397
432
  * TikTok options:
398
- * @param {string} [options.tiktokPrivacyLevel] - PUBLIC_TO_EVERYONE, MUTUAL_FOLLOW_FRIENDS, FOLLOWER_OF_CREATOR, SELF_ONLY
433
+ * @param {string} [options.tiktokPrivacyLevel] - PUBLIC_TO_EVERYONE, MUTUAL_FOLLOW_FRIENDS, FOLLOWER_OF_CREATOR, SELF_ONLY. TikTok decides per account which of these are available (a private account has no PUBLIC_TO_EVERYONE); asking for another one fails with error_code tiktok_privacy_unavailable listing the allowed ones. Omit it to keep the account's own default.
399
434
  * @param {boolean} [options.tiktokDisableDuet] - Disable duet
400
435
  * @param {boolean} [options.tiktokDisableComment] - Disable comments
401
436
  * @param {boolean} [options.tiktokDisableStitch] - Disable stitch
@@ -404,7 +439,24 @@ export class UploadPost {
404
439
  * @param {string} [options.tiktokPostMode] - DIRECT_POST or MEDIA_UPLOAD
405
440
  * @param {boolean} [options.brandContentToggle] - Branded content toggle
406
441
  * @param {boolean} [options.brandOrganicToggle] - Brand organic toggle
407
- *
442
+ *
443
+ * TikTok music, location, cover and draft options. Available on connections that
444
+ * declare the matching `capabilities` (music, location, cover_image, draft) on the
445
+ * TikTok account returned by listUsers() / GET /api/uploadposts/users. If your
446
+ * connection does not have it, the field is ignored, the post still publishes, and
447
+ * the response includes a per-field `warnings` entry — reconnect the TikTok account
448
+ * to enable it.
449
+ * @param {string} [options.tiktokMusicId] - Commercial Music Library track id (see getTiktokTrendingMusic)
450
+ * @param {number} [options.tiktokMusicVolume] - Music volume 0-100 (defaults to 50 when music is set)
451
+ * @param {number} [options.tiktokMusicStart] - Music start offset in ms
452
+ * @param {number} [options.tiktokMusicEnd] - Music end offset in ms
453
+ * @param {number} [options.tiktokOriginalSoundVolume] - Original video audio volume 0-100 (defaults to 50 when music is set, so the original audio is not muted)
454
+ * @param {string} [options.tiktokLocationId] - Location id (see getTiktokLocations)
455
+ * @param {string} [options.tiktokLocationName] - Location name, required whenever tiktokLocationId is set
456
+ * @param {string} [options.tiktokCoverImageUrl] - Custom cover image URL
457
+ * @param {boolean} [options.tiktokIsAiGenerated] - AI-generated content disclosure
458
+ * @param {boolean} [options.tiktokUploadToDraft] - Publish to drafts. When true TikTok ignores the rest of the post settings
459
+ *
408
460
  * Instagram options:
409
461
  * @param {string} [options.instagramMediaType] - REELS or STORIES
410
462
  * @param {boolean} [options.instagramShareToFeed] - Share to feed
@@ -515,10 +567,22 @@ export class UploadPost {
515
567
  * TikTok options:
516
568
  * @param {boolean} [options.tiktokAutoAddMusic] - Auto add music
517
569
  * @param {boolean} [options.tiktokDisableComment] - Disable comments
518
- * @param {number} [options.tiktokPhotoCoverIndex] - Index of photo for cover (0-based)
570
+ * @param {number} [options.tiktokPhotoCoverIndex] - Index of photo for cover (0-based). Sent as `photo_cover_index`
571
+ * @param {string} [options.tiktokPrivacyLevel] - PUBLIC_TO_EVERYONE, MUTUAL_FOLLOW_FRIENDS, FOLLOWER_OF_CREATOR, SELF_ONLY. TikTok requires one on photo posts, so it defaults to PUBLIC_TO_EVERYONE; asking for one the account does not have fails with error_code tiktok_privacy_unavailable listing the allowed ones.
519
572
  * @param {boolean} [options.brandContentToggle] - Branded content toggle
520
573
  * @param {boolean} [options.brandOrganicToggle] - Brand organic toggle
521
- *
574
+ *
575
+ * TikTok music, location and AI disclosure on photo posts. TikTok's photo contract
576
+ * takes the track id alone — no volume/trim, no custom cover, no draft (those are
577
+ * video-only). Available on connections that declare the matching `capabilities`
578
+ * (music, location) on the TikTok account returned by listUsers(); otherwise the
579
+ * field is ignored, the post still publishes, and the response includes a
580
+ * per-field `warnings` entry.
581
+ * @param {string} [options.tiktokMusicId] - Commercial Music Library track id (see getTiktokTrendingMusic)
582
+ * @param {string} [options.tiktokLocationId] - Location id (see getTiktokLocations)
583
+ * @param {string} [options.tiktokLocationName] - Location name, required whenever tiktokLocationId is set
584
+ * @param {boolean} [options.tiktokIsAiGenerated] - AI-generated content disclosure
585
+ *
522
586
  * Instagram options:
523
587
  * @param {string} [options.instagramMediaType] - IMAGE or STORIES
524
588
  * @param {string} [options.instagramCollaborators] - Comma-separated collaborator usernames
@@ -814,6 +878,33 @@ export class UploadPost {
814
878
  });
815
879
  }
816
880
 
881
+ /**
882
+ * Replay per-post metrics already fetched, instead of querying the
883
+ * platforms live.
884
+ *
885
+ * Because it never calls the platform APIs it is not subject to the live
886
+ * post-analytics rate limit (100 requests / 5 minutes), which makes it the
887
+ * right method for paging through a profile's whole post history.
888
+ *
889
+ * @param {string} user - Profile username
890
+ * @param {Object} [options] - Query options
891
+ * @param {string} [options.platform] - Filter by platform (instagram, tiktok, youtube, facebook, linkedin, threads, pinterest, reddit)
892
+ * @param {number} [options.limit=50] - Posts per page (max 200)
893
+ * @param {string} [options.cursor] - Opaque cursor from a previous response's next_cursor
894
+ * @param {string} [options.since] - Start date in YYYY-MM-DD format (defaults to 30 days ago)
895
+ * @param {string} [options.until] - End date in YYYY-MM-DD format (defaults to today)
896
+ * @returns {Promise<Object>} Cached post metrics with next_cursor and has_more
897
+ */
898
+ async getCachedPostAnalytics(user, options = {}) {
899
+ const params = { user };
900
+ if (options.platform) params.platform = options.platform;
901
+ if (options.limit !== undefined) params.limit = options.limit;
902
+ if (options.cursor) params.cursor = options.cursor;
903
+ if (options.since) params.since = options.since;
904
+ if (options.until) params.until = options.until;
905
+ return this._request('/uploadposts/post-analytics/cached', 'GET', params);
906
+ }
907
+
817
908
  /**
818
909
  * Get available metrics configuration for all supported platforms
819
910
  *
@@ -840,11 +931,16 @@ export class UploadPost {
840
931
  * @param {string} user - Profile username
841
932
  * @param {Object} [options]
842
933
  * @param {string} [options.pageUrn] - LinkedIn only. Numeric org ID, full URN, or "me" to force the personal profile.
843
- * @returns {Promise<Object>}
934
+ * @param {number} [options.limit=25] - Items per page, clamped to 1-100. Per-platform caps: TikTok 20, YouTube 50, everything else 100.
935
+ * @param {string} [options.cursor] - Opaque cursor from a previous response's `pagination.next_cursor`.
936
+ * LinkedIn, Discord and Telegram do not support cursors: passing one there returns HTTP 400.
937
+ * @returns {Promise<Object>} `{ success, media, pagination: { limit, next_cursor, has_more } }`
844
938
  */
845
939
  async getMedia(platform, user, options = {}) {
846
940
  const params = { platform, user };
847
941
  if (options.pageUrn) params.page_urn = options.pageUrn;
942
+ if (options.limit !== undefined) params.limit = options.limit;
943
+ if (options.cursor) params.cursor = options.cursor;
848
944
  return this._request('/uploadposts/media', 'GET', params);
849
945
  }
850
946
 
@@ -930,7 +1026,8 @@ export class UploadPost {
930
1026
  * @param {boolean} [options.readonlyCalendar] - Show only a read-only calendar (no editing, no account connection)
931
1027
  * @param {string} [options.connectTitle] - Custom title for the connection page
932
1028
  * @param {string} [options.connectDescription] - Custom description for the connection page
933
- * @param {('en'|'es'|'de'|'fr'|'pt')} [options.language] - Force the connection page language for this profile. When omitted, the page auto-detects the visitor's browser language and falls back to English.
1029
+ * @param {('en'|'es'|'de'|'fr'|'pt'|'pl'|'tr')} [options.language] - Force the connection page language for this profile. When omitted, the page auto-detects the visitor's browser language and falls back to English.
1030
+ * @param {Object.<string, string>} [options.uiLabels] - Flat map of i18n dot-path keys to replacement strings, e.g. `{ 'connect.title': 'Link your accounts' }`. Max 100 entries; keys must match `^[a-zA-Z0-9_.]+$` and values are strings of up to 300 characters. Returned back in the `profile` object of validateJwt.
934
1031
  * @returns {Promise<Object>} JWT and connection URL
935
1032
  */
936
1033
  async generateJwt(username, options = {}) {
@@ -944,6 +1041,7 @@ export class UploadPost {
944
1041
  if (options.connectTitle) body.connect_title = options.connectTitle;
945
1042
  if (options.connectDescription) body.connect_description = options.connectDescription;
946
1043
  if (options.language) body.language = options.language;
1044
+ if (options.uiLabels) body.ui_labels = options.uiLabels;
947
1045
  return this._request('/uploadposts/users/generate-jwt', 'POST', body);
948
1046
  }
949
1047
 
@@ -1202,6 +1300,102 @@ export class UploadPost {
1202
1300
  return this._request('/uploadposts/pinterest/boards', 'GET', params);
1203
1301
  }
1204
1302
 
1303
+ /**
1304
+ * Catalogue filters shared by the trending and search endpoints, so both
1305
+ * address the same cached chart slice.
1306
+ * @private
1307
+ */
1308
+ _tiktokMusicParams(profile, options = {}) {
1309
+ const params = { profile };
1310
+ if (options.genre) params.genre = options.genre;
1311
+ if (options.countryCode) params.country_code = options.countryCode;
1312
+ if (options.dateRange) params.date_range = options.dateRange;
1313
+ return params;
1314
+ }
1315
+
1316
+ /**
1317
+ * Get trending tracks from the TikTok Commercial Music Library.
1318
+ *
1319
+ * Available on connections that declare the `music` capability (see
1320
+ * `capabilities` on the TikTok account returned by listUsers() /
1321
+ * GET /api/uploadposts/users). The returned `id` is what you pass as
1322
+ * `tiktokMusicId` on an upload — not `commercial_music_id`, which TikTok
1323
+ * rejects on public posts.
1324
+ *
1325
+ * @param {string} profile - Profile username
1326
+ * @param {Object} [options] - Query options
1327
+ * @param {string} [options.genre] - Genre filter (e.g. ALL, POP). Defaults to ALL upstream.
1328
+ * @param {string} [options.countryCode] - ISO country code. Defaults to US upstream.
1329
+ * @param {('1DAY'|'7DAY'|'30DAY'|'90DAY')} [options.dateRange] - Trending window. Defaults to 7DAY upstream.
1330
+ * @returns {Promise<Object>} Trending tracks
1331
+ */
1332
+ async getTiktokTrendingMusic(profile, options = {}) {
1333
+ return this._request('/uploadposts/tiktok/music/trending', 'GET',
1334
+ this._tiktokMusicParams(profile, options));
1335
+ }
1336
+
1337
+ /**
1338
+ * Search the TikTok Commercial Music Library by song title or artist.
1339
+ *
1340
+ * TikTok itself has no music search endpoint — its only catalogue read is the
1341
+ * trending chart for a genre/country/period. Upload-Post caches those charts
1342
+ * and matches your text against them, so this searches the trending charts,
1343
+ * not TikTok's whole catalogue. Matching is case- and accent-insensitive and
1344
+ * every word must match.
1345
+ *
1346
+ * Returns the same track objects as getTiktokTrendingMusic(), so the `id` is
1347
+ * again what you pass as `tiktokMusicId` on an upload.
1348
+ *
1349
+ * @param {string} profile - Profile username
1350
+ * @param {Object} [options] - Query options
1351
+ * @param {string} [options.q] - Text matched against titles and artists (max 80 chars). Omit for the chart in trending order.
1352
+ * @param {string} [options.genre] - Genre filter (e.g. ALL, POP). Defaults to ALL upstream.
1353
+ * @param {string} [options.countryCode] - ISO country code choosing which chart is searched. Defaults to US upstream.
1354
+ * @param {('1DAY'|'7DAY'|'30DAY'|'90DAY')} [options.dateRange] - Chart window. Defaults to 7DAY upstream.
1355
+ * @param {number} [options.limit] - Maximum tracks to return (capped at 100 upstream).
1356
+ * @returns {Promise<Object>} Matching tracks plus a `catalog` block describing the corpus searched
1357
+ */
1358
+ async searchTiktokMusic(profile, options = {}) {
1359
+ const params = this._tiktokMusicParams(profile, options);
1360
+ if (options.q) params.q = options.q;
1361
+ if (options.limit !== undefined && options.limit !== null) params.limit = options.limit;
1362
+ return this._request('/uploadposts/tiktok/music/search', 'GET', params);
1363
+ }
1364
+
1365
+ /**
1366
+ * Search TikTok locations (places) to tag on a post.
1367
+ *
1368
+ * Available on connections that declare the `location` capability (see
1369
+ * `capabilities` on the TikTok account returned by listUsers() /
1370
+ * GET /api/uploadposts/users). Pass the returned `location_id` as
1371
+ * `tiktokLocationId` and `location_name` as `tiktokLocationName` on an upload;
1372
+ * TikTok requires both together.
1373
+ *
1374
+ * @param {string} profile - Profile username
1375
+ * @param {string} query - Search query (max 100 characters)
1376
+ * @returns {Promise<Object>} Matching locations (up to 20)
1377
+ */
1378
+ async getTiktokLocations(profile, query) {
1379
+ return this._request('/uploadposts/tiktok/locations', 'GET', { profile, q: query });
1380
+ }
1381
+
1382
+ /**
1383
+ * Get what the connected TikTok account is allowed to publish.
1384
+ *
1385
+ * The point of this call is `privacy_level_options`: TikTok narrows the four
1386
+ * privacy values per account (a private account has no `PUBLIC_TO_EVERYONE`),
1387
+ * and sending one the account does not have fails the upload with
1388
+ * `error_code: "tiktok_privacy_unavailable"`. Ask here to offer only the
1389
+ * values that will work, instead of the full enum.
1390
+ *
1391
+ * @param {string} profile - Profile username
1392
+ * @returns {Promise<Object>} privacy_level_options, max_video_post_duration_sec
1393
+ * and the account's comment/duet/stitch switches
1394
+ */
1395
+ async getTiktokPublishingSettings(profile) {
1396
+ return this._request('/uploadposts/tiktok/settings', 'GET', { profile });
1397
+ }
1398
+
1205
1399
  /**
1206
1400
  * Get Google Business Profile locations for a connected account
1207
1401
  *
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "upload-post",
3
- "version": "2.8.1",
3
+ "version": "2.10.0",
4
4
  "description": "Official client library for Upload-Post API - Cross-platform social media upload for TikTok, Instagram, YouTube, LinkedIn, Facebook, Pinterest, Threads, Reddit, Bluesky, and X (Twitter)",
5
5
  "main": "index.js",
6
6
  "types": "index.d.ts",
@@ -26,7 +26,10 @@
26
26
  "x",
27
27
  "api-client",
28
28
  "social-media-api",
29
- "upload-post"
29
+ "upload-post",
30
+ "mcp",
31
+ "model-context-protocol",
32
+ "ai-agent"
30
33
  ],
31
34
  "repository": {
32
35
  "type": "git",