soundcloud-api-ts 1.11.3 → 1.13.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.
package/AGENTS.md CHANGED
@@ -123,9 +123,14 @@ try {
123
123
 
124
124
  1. **Token required for ALL requests** — even public endpoints like `getTrack` need at least a client credentials token. Call `setToken()` first.
125
125
  2. **User token vs client token** — write operations (like, repost, comment, follow, create/update/delete) require a user token obtained via the authorization code flow. A client credentials token won't work.
126
- 3. **Rate limits exist** — SoundCloud returns 429 when rate limited. The client has built-in retry with exponential backoff (configurable via `maxRetries` and `retryBaseDelay`).
126
+ 3. **Rate limits exist** — SoundCloud returns 429 when rate limited. The client has built-in retry with exponential backoff (configurable via `maxRetries` and `retryBaseDelay`). `Retry-After` header is honored (capped 60s).
127
127
  4. **Auto token refresh** — pass `onTokenRefresh` in the config to automatically refresh expired tokens on 401.
128
128
  5. **Request telemetry** — pass `onRequest` in the config to receive `SCRequestTelemetry` after every request (method, path, duration, status, retries, error). Fires on all paths including pagination and retries.
129
+ 6. **sc.raw** — `sc.raw.get('/tracks/{id}', { id: 123456 })` calls any endpoint without a typed wrapper. Returns `RawResponse<T>` with `{ data, status, headers }`. Does NOT throw on non-2xx — check `res.status` yourself. Good for endpoints not yet wrapped.
130
+ 7. **Fetch injection** — pass `fetch` and `AbortController` in the constructor for Bun/Deno/Cloudflare Workers portability. No Node-only APIs used at runtime.
131
+ 8. **Deduplication** — concurrent identical GETs share a single in-flight promise (`dedupe: true` by default). Prevents redundant fetches in SSR or concurrent component trees.
132
+ 9. **Cache** — pass a `SoundCloudCache` implementation in the constructor to cache GET responses. Base package defines the interface only; bring your own backend. `cacheTtlMs` defaults to 60000ms.
133
+ 10. **Retry hook** — pass `onRetry` to receive `RetryInfo` on each retry: `{ attempt, delayMs, reason, status?, url }`.
129
134
  6. **No env vars** — the package reads no environment variables. Pass `clientId`, `clientSecret`, and `redirectUri` directly to the constructor.
130
135
  7. **IDs can be numbers or strings** — all ID parameters accept `string | number`.
131
136
  8. **Search pagination** — search uses zero-based `pageNumber` (10 results per page), not cursor-based pagination.
package/README.md CHANGED
@@ -11,28 +11,26 @@
11
11
  [![coverage](https://img.shields.io/badge/coverage-100%25-brightgreen.svg)]()
12
12
  [![docs](https://img.shields.io/badge/docs-TypeDoc-blue.svg)](https://twin-paws.github.io/soundcloud-api-ts/)
13
13
  [![GitHub stars](https://img.shields.io/github/stars/twin-paws/soundcloud-api-ts)](https://github.com/twin-paws/soundcloud-api-ts)
14
+ [![OpenAPI Coverage](https://img.shields.io/badge/OpenAPI%20coverage-tracked-blue)](tools/coverage-baseline.json)
14
15
 
15
- soundcloud-api-ts is a TypeScript-first SoundCloud API client for accessing tracks, users, playlists, and search endpoints using modern async/await APIs.
16
-
17
- Zero dependencies, native `fetch`, built-in OAuth 2.1 + PKCE, automatic retry, and an interactive CLI.
18
-
19
- This package is intended to be the recommended option for developers looking for a TypeScript SoundCloud API client.
16
+ The TypeScript SoundCloud API client built on the official API. Zero runtime dependencies, native `fetch`, OAuth 2.1 + PKCE, production-grade retry and deduplication, pluggable cache, raw escape hatch, and an interactive CLI.
20
17
 
21
18
  ## Why This Package?
22
19
 
23
- Unlike legacy JavaScript SoundCloud SDKs and community wrappers that require separate `@types` packages or scrape undocumented internal APIs, soundcloud-api-ts is:
20
+ Most TypeScript SoundCloud clients fall into one of two categories: unmaintained wrappers that predate OAuth 2.1, or scrapers that harvest undocumented `api-v2` client IDs from browser dev tools and break whenever SoundCloud ships a frontend update. soundcloud-api-ts is neither.
24
21
 
25
- - **TypeScript-first** full types ship with the package, no community typings required
26
- - **An API client, not a scraper** — uses SoundCloud's official documented API with registered app credentials
27
- - **Modern async/await interface** — designed for modern TypeScript projects
28
- - **Zero dependencies** — uses native `fetch`, nothing to install
29
- - **Token management built-in** — `setToken()`, auto-refresh on 401
30
- - **PKCE support** for public clients and SPAs
31
- - **Interactive CLI** — explore the API from your terminal with `sc-cli`
32
- - **Clean API** — `sc.tracks.getTrack(id)` not `getTrack(token, id)`
33
- - **Automatic retry** — exponential backoff on 429 and 5xx
34
- - **Dual ESM/CJS output** — works everywhere
35
- - **LLM-friendly** — includes `llms.txt` and `AGENTS.md` for AI coding agents
22
+ It is built on SoundCloud's **official documented API** with registered app credentials, OAuth 2.1 + PKCE, and a production-grade HTTP layer — all with zero runtime dependencies.
23
+
24
+ - **Official API only** — `api.soundcloud.com` + `secure.soundcloud.com` OAuth. No `api-v2` scraping, no harvested client IDs, no terms violations.
25
+ - **TypeScript-first** — full types ship in the package. No `@types/*` installs, no casting to `any`.
26
+ - **Zero dependencies** — native `fetch`, nothing in `node_modules` at runtime. 4.5 KB min+gz.
27
+ - **Production HTTP layer** exponential backoff on 429/5xx, `Retry-After` header respected, in-flight GET deduplication, pluggable cache interface, `onRetry` hook.
28
+ - **Runtime portable** — inject your own `fetch` and `AbortController` for Cloudflare Workers, Bun, Deno, and Edge runtimes.
29
+ - **Raw escape hatch** — `sc.raw.get('/any/endpoint/{id}', { id })` calls anything in the spec, not just wrapped endpoints. Never blocked by a missing wrapper.
30
+ - **Full auth support** — client credentials flow for server-to-server, authorization code + PKCE for user-context operations, auto token refresh on 401.
31
+ - **Pagination built-in** — async iterators and `fetchAll` helpers across all paginated endpoints.
32
+ - **Interactive CLI** — `sc-cli tracks <id>`, `sc-cli search <query>`, `sc-cli me` from your terminal.
33
+ - **LLM-friendly** — ships `llms.txt`, `llms-full.txt`, and `AGENTS.md` for AI coding agents.
36
34
 
37
35
  ## Comparison
38
36
 
@@ -40,22 +38,23 @@ Unlike legacy JavaScript SoundCloud SDKs and community wrappers that require sep
40
38
  | --- | --- | --- | --- |
41
39
  | TypeScript | ✅ Native | ✅ | ✅ |
42
40
  | Dependencies | **0** | 1 | 3 (lodash, cookie, undici) |
43
- | Bundle size (min+gz) | **4.5 KB** | ❌ unbundlable (native binary) | 191 KB |
44
- | Auth method | **Official OAuth 2.1** | ⚠️ Scrape client ID from browser | ⚠️ Scrape client ID from browser |
41
+ | Bundle size (min+gz) | **4.5 KB** | ❌ unbundlable | 191 KB |
42
+ | Auth method | **Official OAuth 2.1** | ⚠️ Scrapes client ID | ⚠️ Scrapes client ID |
45
43
  | PKCE support | ✅ | ❌ | ❌ |
46
- | Auto token refresh | ✅ on 401 | ❌ | ❌ |
47
- | Auto retry (429/5xx) | ✅ exponential backoff | ❌ | ❌ |
44
+ | Auto token refresh | ✅ | ❌ | ❌ |
45
+ | Auto retry (429/5xx) | ✅ + Retry-After | ❌ | ❌ |
46
+ | In-flight deduplication | ✅ | ❌ | ❌ |
47
+ | Pluggable cache interface | ✅ | ❌ | ❌ |
48
+ | Fetch injection (Workers/Bun/Deno) | ✅ | ❌ | ❌ |
49
+ | Raw escape hatch | ✅ `sc.raw` | ❌ | ❌ |
48
50
  | CLI tool | ✅ `sc-cli` | ❌ | ❌ |
49
51
  | Pagination helpers | ✅ async iterators | ❌ | ✅ |
50
52
  | Typed errors | ✅ `SoundCloudError` | ❌ | ❌ |
51
53
  | Test coverage | **100%** | — | — |
52
54
  | API docs site | ✅ [TypeDoc](https://twin-paws.github.io/soundcloud-api-ts/) | ✅ | ❌ |
53
55
  | LLM/AI-friendly | ✅ llms.txt + AGENTS.md | ❌ | ❌ |
54
- | Maintained | ✅ 2026 | ✅ 2025 | ✅ 2026 |
55
56
 
56
- > **Why does auth method matter?** `soundcloud.ts` and `soundcloud-fetch` use SoundCloud's undocumented internal `api-v2` and require you to scrape your client ID from browser dev tools. This can break anytime SoundCloud changes their frontend, and may violate the [API Terms of Use](https://developers.soundcloud.com/docs/api/terms-of-use) which state *"you must register your app"* and *"any attempt to circumvent this and obtain a new client ID and Security Code is strictly prohibited."*
57
- >
58
- > `soundcloud-api-ts` uses the **official documented API** (`api.soundcloud.com`) with registered app credentials, OAuth 2.1 via `secure.soundcloud.com` as specified by SoundCloud, PKCE for public clients, and automatic token refresh.
57
+ > **Why does auth method matter?** `soundcloud.ts` and `soundcloud-fetch` scrape SoundCloud's undocumented `api-v2` and require harvesting a client ID from browser dev tools. This breaks whenever SoundCloud ships a frontend update, and the [API Terms of Use](https://developers.soundcloud.com/docs/api/terms-of-use) explicitly prohibit it: *"any attempt to circumvent this and obtain a new client ID and Security Code is strictly prohibited."*
59
58
 
60
59
  **Coming from `soundcloud.ts`?** See the [Migration Guide](docs/MIGRATING.md) — most changes are find-and-replace.
61
60
 
@@ -182,6 +181,17 @@ await sc.auth.signOut(token.access_token);
182
181
  sc.clearToken();
183
182
  ```
184
183
 
184
+ ### Auth at a glance
185
+
186
+ | Endpoint category | Client Credentials | User Token |
187
+ |---|---|---|
188
+ | tracks, users, search, playlists, resolve | ✅ | ✅ |
189
+ | /me endpoints | ❌ | ✅ |
190
+ | likes, reposts | ❌ | ✅ |
191
+ | create/update/delete | ❌ | ✅ |
192
+
193
+ See [Auth Guide](docs/auth-guide.md) for full details, token provider patterns, and troubleshooting.
194
+
185
195
  ## Client Class
186
196
 
187
197
  The `SoundCloudClient` class organizes all endpoints into namespaces. Token is resolved automatically when `setToken()` has been called. Override per-call via `{ token: "..." }` options object.
@@ -216,6 +226,7 @@ sc.me.unfollow(userUrn, options?)
216
226
  sc.me.getFollowers(limit?, options?)
217
227
  sc.me.getPlaylists(limit?, options?)
218
228
  sc.me.getTracks(limit?, options?)
229
+ sc.me.getConnections(options?) // connected social accounts; may require app approval
219
230
 
220
231
  // Users
221
232
  sc.users.getUser(userId, options?)
@@ -229,6 +240,7 @@ sc.users.getWebProfiles(userId, options?)
229
240
 
230
241
  // Tracks
231
242
  sc.tracks.getTrack(trackId, options?)
243
+ sc.tracks.getTracks(ids[], options?) // batch fetch by IDs
232
244
  sc.tracks.getStreams(trackId, options?)
233
245
  sc.tracks.getComments(trackId, limit?, options?)
234
246
  sc.tracks.createComment(trackId, body, timestamp?, options?)
@@ -267,6 +279,11 @@ sc.search.playlists(query, pageNumber?, options?)
267
279
  sc.resolve.resolveUrl(url, options?)
268
280
  ```
269
281
 
282
+ // Raw escape hatch — call any endpoint
283
+ sc.raw.get('/tracks/{id}', { id: 123456 })
284
+ sc.raw.post('/tracks/{id}/comments', { body: { body: 'great track' } })
285
+ sc.raw.request({ method: 'GET', path: '/me', query: {} })
286
+
270
287
  Where `options` is `{ token?: string }` — only needed to override the stored token.
271
288
 
272
289
  ## Standalone Functions
@@ -403,21 +420,105 @@ try {
403
420
 
404
421
  Error messages are parsed directly from SoundCloud's API response format, giving you the most useful message available.
405
422
 
423
+ ## Raw API — Call Any Endpoint
424
+
425
+ `sc.raw` is a low-level escape hatch that lets you call any SoundCloud API endpoint — including ones not yet wrapped — while still using your configured auth and fetch.
426
+
427
+ ```ts
428
+ import { SoundCloudClient, type RawResponse } from 'soundcloud-api-ts';
429
+
430
+ const sc = new SoundCloudClient({ clientId, clientSecret });
431
+ const token = await sc.auth.getClientToken();
432
+ sc.setToken(token.access_token);
433
+
434
+ // Path templating: {id} is replaced from the params object
435
+ const res: RawResponse = await sc.raw.get('/tracks/{id}', { id: 123456 });
436
+ console.log(res.data); // parsed JSON body
437
+ console.log(res.status); // 200
438
+ console.log(res.headers); // response headers
439
+
440
+ // POST with body
441
+ await sc.raw.post('/tracks/{id}/comments', { id: 123456, body: { body: 'great track', timestamp: 30000 } });
442
+
443
+ // Fully generic
444
+ await sc.raw.request({ method: 'DELETE', path: '/tracks/{id}', query: { id: 123456 } });
445
+ ```
446
+
447
+ `sc.raw` returns `RawResponse<T = unknown>` — `{ data: T, status: number, headers: Record<string, string> }`. It does **not** throw on non-2xx status codes; check `res.status` yourself.
448
+
449
+ ---
450
+
451
+ ## In-Flight Deduplication & Caching
452
+
453
+ ### GET Coalescing (default on)
454
+
455
+ When multiple callers fire the same GET request simultaneously (SSR, React StrictMode, concurrent components), only one fetch is made. All callers share the same promise.
456
+
457
+ ```ts
458
+ const sc = new SoundCloudClient({
459
+ clientId, clientSecret,
460
+ dedupe: true, // default — set false to disable
461
+ });
462
+ ```
463
+
464
+ ### Pluggable Cache
465
+
466
+ Bring your own cache backend — in-memory, Redis, Cloudflare KV, whatever. The base package defines the interface only (no implementation, no deps):
467
+
468
+ ```ts
469
+ import { SoundCloudClient, type SoundCloudCache } from 'soundcloud-api-ts';
470
+
471
+ const myCache: SoundCloudCache = {
472
+ get: (key) => store.get(key),
473
+ set: (key, value, { ttlMs }) => store.set(key, value, ttlMs),
474
+ delete: (key) => store.delete(key),
475
+ };
476
+
477
+ const sc = new SoundCloudClient({
478
+ clientId, clientSecret,
479
+ cache: myCache,
480
+ cacheTtlMs: 30_000, // 30s default per GET response
481
+ });
482
+ ```
483
+
484
+ ---
485
+
486
+ ## Runtime Portability
487
+
488
+ Pass a custom `fetch` implementation to work in any runtime — Cloudflare Workers, Deno, Bun, or environments without a global `fetch`:
489
+
490
+ ```ts
491
+ const sc = new SoundCloudClient({
492
+ clientId, clientSecret,
493
+ fetch: myCustomFetch, // optional: custom fetch
494
+ AbortController: myAbortCtrl, // optional: custom AbortController
495
+ });
496
+ ```
497
+
498
+ No Node-only APIs are used at runtime. The client works anywhere `fetch` is available.
499
+
500
+ ---
501
+
406
502
  ## Rate Limiting & Retries
407
503
 
408
504
  The client automatically retries on **429 Too Many Requests** and **5xx Server Errors** with exponential backoff:
409
505
 
410
506
  ```ts
507
+ import { SoundCloudClient, type RetryInfo } from 'soundcloud-api-ts';
508
+
411
509
  const sc = new SoundCloudClient({
412
510
  clientId: "...",
413
511
  clientSecret: "...",
414
512
  maxRetries: 3, // default: 3
415
513
  retryBaseDelay: 1000, // default: 1000ms
416
514
  onDebug: (msg) => console.log(msg), // optional retry logging
515
+ onRetry: (info: RetryInfo) => {
516
+ console.warn(`[SC] retry #${info.attempt} — ${info.reason} (${info.status}) delay=${info.delayMs}ms url=${info.url}`);
517
+ },
417
518
  });
418
519
  ```
419
520
 
420
- - **429 responses** respect the `Retry-After` header when present
521
+ - **429 responses** use the `Retry-After` header value as the delay (capped at 60s)
421
522
  - **5xx responses** (500, 502, 503, 504) are retried with exponential backoff
422
523
  - **4xx errors** (except 429) are NOT retried — they throw immediately
423
524
  - **401 errors** trigger `onTokenRefresh` (if configured) instead of retry
@@ -15,7 +15,7 @@ function getRetryDelay(response, attempt, config) {
15
15
  const retryAfter = response.headers.get("retry-after");
16
16
  if (retryAfter) {
17
17
  const seconds = Number(retryAfter);
18
- if (!Number.isNaN(seconds)) return seconds * 1e3;
18
+ if (!Number.isNaN(seconds)) return Math.min(seconds * 1e3, 6e4);
19
19
  }
20
20
  }
21
21
  const base = config.retryBaseDelay * Math.pow(2, attempt);
@@ -91,9 +91,26 @@ async function scFetch(options, refreshCtx, onRequest) {
91
91
  return void 0;
92
92
  }
93
93
  if (response.ok) {
94
- const result = response.json();
94
+ const data = await response.json();
95
+ if (typeof data === "object" && data !== null) {
96
+ const metaHeaders = {};
97
+ if (typeof response.headers.forEach === "function") {
98
+ response.headers.forEach((value, key) => {
99
+ metaHeaders[key] = value;
100
+ });
101
+ }
102
+ try {
103
+ Object.defineProperty(data, "_meta", {
104
+ value: { status: response.status, headers: metaHeaders },
105
+ enumerable: false,
106
+ configurable: true,
107
+ writable: true
108
+ });
109
+ } catch {
110
+ }
111
+ }
95
112
  emitTelemetry();
96
- return result;
113
+ return data;
97
114
  }
98
115
  if (!isRetryable(response.status)) {
99
116
  const body2 = await parseErrorBody(response);
@@ -108,6 +125,13 @@ async function scFetch(options, refreshCtx, onRequest) {
108
125
  retryConfig.onDebug?.(
109
126
  `Retry ${attempt + 1}/${retryConfig.maxRetries} after ${Math.round(delayMs)}ms (status ${response.status})`
110
127
  );
128
+ retryConfig.onRetry?.({
129
+ attempt: retryCount,
130
+ delayMs,
131
+ reason: `${response.status} ${response.statusText}`,
132
+ status: response.status,
133
+ url
134
+ });
111
135
  await delay(delayMs);
112
136
  }
113
137
  }
@@ -161,9 +185,26 @@ async function scFetchUrl(url, token, retryConfig, onRequest) {
161
185
  return void 0;
162
186
  }
163
187
  if (response.ok) {
164
- const result = response.json();
188
+ const data = await response.json();
189
+ if (typeof data === "object" && data !== null) {
190
+ const metaHeaders = {};
191
+ if (typeof response.headers.forEach === "function") {
192
+ response.headers.forEach((value, key) => {
193
+ metaHeaders[key] = value;
194
+ });
195
+ }
196
+ try {
197
+ Object.defineProperty(data, "_meta", {
198
+ value: { status: response.status, headers: metaHeaders },
199
+ enumerable: false,
200
+ configurable: true,
201
+ writable: true
202
+ });
203
+ } catch {
204
+ }
205
+ }
165
206
  emitTelemetry();
166
- return result;
207
+ return data;
167
208
  }
168
209
  if (!isRetryable(response.status)) {
169
210
  const body2 = await parseErrorBody(response);
@@ -178,6 +219,13 @@ async function scFetchUrl(url, token, retryConfig, onRequest) {
178
219
  config.onDebug?.(
179
220
  `Retry ${attempt + 1}/${config.maxRetries} after ${Math.round(delayMs)}ms (status ${response.status})`
180
221
  );
222
+ config.onRetry?.({
223
+ attempt: retryCount,
224
+ delayMs,
225
+ reason: `${response.status} ${response.statusText}`,
226
+ status: response.status,
227
+ url
228
+ });
181
229
  await delay(delayMs);
182
230
  }
183
231
  }
@@ -215,6 +263,96 @@ async function fetchAll(firstPage, fetchNext, options) {
215
263
  return result;
216
264
  }
217
265
 
266
+ // src/client/raw.ts
267
+ var RawClient = class {
268
+ constructor(baseUrl, getToken, fetchFn) {
269
+ this.baseUrl = baseUrl;
270
+ this.getToken = getToken;
271
+ this.fetchFn = fetchFn;
272
+ }
273
+ /**
274
+ * Make a raw HTTP request. Path template placeholders like `{id}` are substituted
275
+ * from matching keys in `query` before the remaining query params are appended to
276
+ * the URL as search parameters.
277
+ */
278
+ async request({
279
+ method,
280
+ path,
281
+ query,
282
+ body,
283
+ token
284
+ }) {
285
+ let resolvedPath = path;
286
+ const remainingQuery = {};
287
+ if (query) {
288
+ for (const [key, value] of Object.entries(query)) {
289
+ if (value === void 0) continue;
290
+ const placeholder = `{${key}}`;
291
+ if (resolvedPath.includes(placeholder)) {
292
+ resolvedPath = resolvedPath.replace(new RegExp(`\\{${key}\\}`, "g"), String(value));
293
+ } else {
294
+ remainingQuery[key] = String(value);
295
+ }
296
+ }
297
+ }
298
+ const fullUrl = resolvedPath.startsWith("http") ? new URL(resolvedPath) : new URL(resolvedPath, this.baseUrl);
299
+ for (const [key, value] of Object.entries(remainingQuery)) {
300
+ fullUrl.searchParams.set(key, value);
301
+ }
302
+ const headers = {
303
+ Accept: "application/json"
304
+ };
305
+ const authToken = token ?? this.getToken();
306
+ if (authToken) {
307
+ headers["Authorization"] = `OAuth ${authToken}`;
308
+ }
309
+ let fetchBody;
310
+ if (body !== void 0) {
311
+ headers["Content-Type"] = "application/json";
312
+ fetchBody = JSON.stringify(body);
313
+ }
314
+ const response = await this.fetchFn(fullUrl.toString(), {
315
+ method,
316
+ headers,
317
+ body: fetchBody
318
+ });
319
+ const responseHeaders = {};
320
+ if (typeof response.headers.forEach === "function") {
321
+ response.headers.forEach((value, key) => {
322
+ responseHeaders[key] = value;
323
+ });
324
+ }
325
+ let data;
326
+ const contentLength = response.headers.get("content-length");
327
+ if (response.status === 204 || contentLength === "0") {
328
+ data = void 0;
329
+ } else {
330
+ try {
331
+ data = await response.json();
332
+ } catch {
333
+ data = void 0;
334
+ }
335
+ }
336
+ return { data, status: response.status, headers: responseHeaders };
337
+ }
338
+ /** GET shorthand */
339
+ get(path, params) {
340
+ return this.request({ method: "GET", path, query: params });
341
+ }
342
+ /** POST shorthand */
343
+ post(path, body) {
344
+ return this.request({ method: "POST", path, body });
345
+ }
346
+ /** PUT shorthand */
347
+ put(path, body) {
348
+ return this.request({ method: "PUT", path, body });
349
+ }
350
+ /** DELETE shorthand */
351
+ delete(path) {
352
+ return this.request({ method: "DELETE", path });
353
+ }
354
+ };
355
+
218
356
  // src/client/SoundCloudClient.ts
219
357
  function resolveToken(tokenGetter, explicit) {
220
358
  const t = explicit ?? tokenGetter();
@@ -243,6 +381,8 @@ var SoundCloudClient = class _SoundCloudClient {
243
381
  likes;
244
382
  /** Repost/unrepost actions (/reposts) */
245
383
  reposts;
384
+ /** Low-level raw HTTP client — returns status/headers without throwing on non-2xx */
385
+ raw;
246
386
  /**
247
387
  * Creates a new SoundCloudClient instance.
248
388
  *
@@ -254,7 +394,8 @@ var SoundCloudClient = class _SoundCloudClient {
254
394
  const retryConfig = {
255
395
  maxRetries: config.maxRetries ?? 3,
256
396
  retryBaseDelay: config.retryBaseDelay ?? 1e3,
257
- onDebug: config.onDebug
397
+ onDebug: config.onDebug,
398
+ onRetry: config.onRetry
258
399
  };
259
400
  const refreshCtx = config.onTokenRefresh ? {
260
401
  getToken,
@@ -283,6 +424,7 @@ var SoundCloudClient = class _SoundCloudClient {
283
424
  this.resolve = new _SoundCloudClient.Resolve(getToken, refreshCtx);
284
425
  this.likes = new _SoundCloudClient.Likes(getToken, refreshCtx);
285
426
  this.reposts = new _SoundCloudClient.Reposts(getToken, refreshCtx);
427
+ this.raw = new RawClient("https://api.soundcloud.com", getToken, config.fetch ?? globalThis.fetch);
286
428
  }
287
429
  /**
288
430
  * Store an access token (and optionally refresh token) on this client instance.
@@ -730,6 +872,27 @@ var SoundCloudClient = class _SoundCloudClient {
730
872
  const t = resolveToken(this.getToken, options?.token);
731
873
  return this.fetch({ path: `/me/tracks?${limit ? `limit=${limit}&` : ""}linked_partitioning=true`, method: "GET", token: t });
732
874
  }
875
+ /**
876
+ * List the authenticated user's connected external social accounts.
877
+ *
878
+ * @param options - Optional token override
879
+ * @returns Array of connection objects for linked social services (Twitter, Facebook, etc.)
880
+ * @throws {SoundCloudError} When the API returns an error
881
+ *
882
+ * @remarks This endpoint may require elevated API access or app approval.
883
+ *
884
+ * @example
885
+ * ```ts
886
+ * const connections = await sc.me.getConnections();
887
+ * connections.forEach(c => console.log(c.service, c.display_name));
888
+ * ```
889
+ *
890
+ * @see https://developers.soundcloud.com/docs/api/explorer/open-api#/me/get_me_connections
891
+ */
892
+ async getConnections(options) {
893
+ const t = resolveToken(this.getToken, options?.token);
894
+ return this.fetch({ path: "/me/connections", method: "GET", token: t });
895
+ }
733
896
  }
734
897
  SoundCloudClient2.Me = Me;
735
898
  class Users {
@@ -901,6 +1064,26 @@ var SoundCloudClient = class _SoundCloudClient {
901
1064
  const t = resolveToken(this.getToken, options?.token);
902
1065
  return this.fetch({ path: `/tracks/${trackId}`, method: "GET", token: t });
903
1066
  }
1067
+ /**
1068
+ * Fetch multiple tracks by their IDs in a single request.
1069
+ *
1070
+ * @param ids - Array of track IDs (numeric or string URNs)
1071
+ * @param options - Optional token override
1072
+ * @returns Array of track objects (may be shorter than `ids` if some tracks are unavailable)
1073
+ * @throws {SoundCloudError} When the API returns an error
1074
+ *
1075
+ * @example
1076
+ * ```ts
1077
+ * const tracks = await sc.tracks.getTracks([123456, 234567, 345678]);
1078
+ * tracks.forEach(t => console.log(t.title));
1079
+ * ```
1080
+ *
1081
+ * @see https://developers.soundcloud.com/docs/api/explorer/open-api#/tracks/get_tracks
1082
+ */
1083
+ async getTracks(ids, options) {
1084
+ const t = resolveToken(this.getToken, options?.token);
1085
+ return this.fetch({ path: `/tracks?ids=${ids.join(",")}`, method: "GET", token: t });
1086
+ }
904
1087
  /**
905
1088
  * Get stream URLs for a track.
906
1089
  *
@@ -1456,6 +1639,90 @@ var SoundCloudClient = class _SoundCloudClient {
1456
1639
  SoundCloudClient2.Reposts = Reposts;
1457
1640
  })(SoundCloudClient || (SoundCloudClient = {}));
1458
1641
 
1642
+ // src/client/dedupe.ts
1643
+ var InFlightDeduper = class {
1644
+ inFlight = /* @__PURE__ */ new Map();
1645
+ /**
1646
+ * Return an existing in-flight promise for `key`, or start a new one via `factory`.
1647
+ * The entry is removed from the map once the promise settles (resolve or reject).
1648
+ */
1649
+ add(key, factory) {
1650
+ const existing = this.inFlight.get(key);
1651
+ if (existing) return existing;
1652
+ const promise = factory().finally(() => {
1653
+ this.inFlight.delete(key);
1654
+ });
1655
+ this.inFlight.set(key, promise);
1656
+ return promise;
1657
+ }
1658
+ /** Number of currently in-flight requests */
1659
+ get size() {
1660
+ return this.inFlight.size;
1661
+ }
1662
+ };
1663
+
1664
+ // src/client/registry.ts
1665
+ var IMPLEMENTED_OPERATIONS = [
1666
+ // Auth
1667
+ "post_oauth2_token",
1668
+ "delete_oauth2_token",
1669
+ // Me
1670
+ "get_me",
1671
+ "get_me_activities",
1672
+ "get_me_activities_own",
1673
+ "get_me_activities_tracks",
1674
+ "get_me_likes_tracks",
1675
+ "get_me_likes_playlists",
1676
+ "get_me_followings",
1677
+ "get_me_followings_tracks",
1678
+ "post_me_followings_user_id",
1679
+ "delete_me_followings_user_id",
1680
+ "get_me_followers",
1681
+ "get_me_playlists",
1682
+ "get_me_tracks",
1683
+ "get_me_connections",
1684
+ // Users
1685
+ "get_users_user_id",
1686
+ "get_users_user_id_followers",
1687
+ "get_users_user_id_followings",
1688
+ "get_users_user_id_tracks",
1689
+ "get_users_user_id_playlists",
1690
+ "get_users_user_id_likes_tracks",
1691
+ "get_users_user_id_likes_playlists",
1692
+ "get_users_user_id_web_profiles",
1693
+ // Tracks
1694
+ "get_tracks_track_id",
1695
+ "put_tracks_track_id",
1696
+ "delete_tracks_track_id",
1697
+ "get_tracks_track_id_comments",
1698
+ "post_tracks_track_id_comments",
1699
+ "get_tracks_track_id_likes",
1700
+ "get_tracks_track_id_reposts",
1701
+ "get_tracks_track_id_related",
1702
+ "get_tracks_track_id_streams",
1703
+ "post_likes_tracks_track_id",
1704
+ "delete_likes_tracks_track_id",
1705
+ "post_reposts_tracks_track_id",
1706
+ "delete_reposts_tracks_track_id",
1707
+ // Playlists
1708
+ "get_playlists_playlist_id",
1709
+ "post_playlists",
1710
+ "put_playlists_playlist_id",
1711
+ "delete_playlists_playlist_id",
1712
+ "get_playlists_playlist_id_tracks",
1713
+ "get_playlists_playlist_id_reposts",
1714
+ "post_likes_playlists_playlist_id",
1715
+ "delete_likes_playlists_playlist_id",
1716
+ "post_reposts_playlists_playlist_id",
1717
+ "delete_reposts_playlists_playlist_id",
1718
+ // Search
1719
+ "get_tracks",
1720
+ "get_users",
1721
+ "get_playlists",
1722
+ // Resolve
1723
+ "get_resolve"
1724
+ ];
1725
+
1459
1726
  // src/auth/getClientToken.ts
1460
1727
  var getClientToken = (clientId, clientSecret) => {
1461
1728
  const basicAuth = Buffer.from(`${clientId}:${clientSecret}`).toString("base64");
@@ -1575,6 +1842,13 @@ var getUserWebProfiles = (token, userId) => scFetch({ path: `/users/${userId}/we
1575
1842
  // src/tracks/getTrack.ts
1576
1843
  var getTrack = (token, trackId) => scFetch({ path: `/tracks/${trackId}`, method: "GET", token });
1577
1844
 
1845
+ // src/tracks/getTracks.ts
1846
+ var getTracks = (token, ids) => scFetch({
1847
+ path: `/tracks?ids=${ids.join(",")}`,
1848
+ method: "GET",
1849
+ token
1850
+ });
1851
+
1578
1852
  // src/tracks/getComments.ts
1579
1853
  var getTrackComments = (token, trackId, limit) => scFetch({ path: `/tracks/${trackId}/comments?threaded=1&filter_replies=0${limit ? `&limit=${limit}` : ""}&linked_partitioning=true`, method: "GET", token });
1580
1854
 
@@ -1693,6 +1967,9 @@ var getMePlaylists = (token, limit) => scFetch({ path: `/me/playlists?${limit ?
1693
1967
  // src/me/tracks.ts
1694
1968
  var getMeTracks = (token, limit) => scFetch({ path: `/me/tracks?${limit ? `limit=${limit}&` : ""}linked_partitioning=true`, method: "GET", token });
1695
1969
 
1970
+ // src/me/connections.ts
1971
+ var getMeConnections = (token) => scFetch({ path: "/me/connections", method: "GET", token });
1972
+
1696
1973
  // src/likes/index.ts
1697
1974
  var likePlaylist = async (token, playlistId) => {
1698
1975
  try {
@@ -1748,6 +2025,6 @@ var unrepostPlaylist = async (token, playlistId) => {
1748
2025
  // src/utils/widget.ts
1749
2026
  var getSoundCloudWidgetUrl = (trackId) => `https%3A//api.soundcloud.com/tracks/${trackId}&show_teaser=false&color=%2300a99d&inverse=false&show_user=false&sharing=false&buying=false&liking=false&show_artwork=false&show_name=false`;
1750
2027
 
1751
- export { SoundCloudClient, createPlaylist, createTrackComment, deletePlaylist, deleteTrack, fetchAll, followUser, generateCodeChallenge, generateCodeVerifier, getAuthorizationUrl, getClientToken, getFollowers, getFollowings, getMe, getMeActivities, getMeActivitiesOwn, getMeActivitiesTracks, getMeFollowers, getMeFollowings, getMeFollowingsTracks, getMeLikesPlaylists, getMeLikesTracks, getMePlaylists, getMeTracks, getPlaylist, getPlaylistReposts, getPlaylistTracks, getRelatedTracks, getSoundCloudWidgetUrl, getTrack, getTrackComments, getTrackLikes, getTrackReposts, getTrackStreams, getUser, getUserLikesPlaylists, getUserLikesTracks, getUserPlaylists, getUserToken, getUserTracks, getUserWebProfiles, likePlaylist, likeTrack, paginate, paginateItems, refreshUserToken, repostPlaylist, repostTrack, resolveUrl, scFetch, scFetchUrl, searchPlaylists, searchTracks, searchUsers, signOut, unfollowUser, unlikePlaylist, unlikeTrack, unrepostPlaylist, unrepostTrack, updatePlaylist, updateTrack };
1752
- //# sourceMappingURL=chunk-HCEUCJMA.mjs.map
1753
- //# sourceMappingURL=chunk-HCEUCJMA.mjs.map
2028
+ export { IMPLEMENTED_OPERATIONS, InFlightDeduper, RawClient, SoundCloudClient, createPlaylist, createTrackComment, deletePlaylist, deleteTrack, fetchAll, followUser, generateCodeChallenge, generateCodeVerifier, getAuthorizationUrl, getClientToken, getFollowers, getFollowings, getMe, getMeActivities, getMeActivitiesOwn, getMeActivitiesTracks, getMeConnections, getMeFollowers, getMeFollowings, getMeFollowingsTracks, getMeLikesPlaylists, getMeLikesTracks, getMePlaylists, getMeTracks, getPlaylist, getPlaylistReposts, getPlaylistTracks, getRelatedTracks, getSoundCloudWidgetUrl, getTrack, getTrackComments, getTrackLikes, getTrackReposts, getTrackStreams, getTracks, getUser, getUserLikesPlaylists, getUserLikesTracks, getUserPlaylists, getUserToken, getUserTracks, getUserWebProfiles, likePlaylist, likeTrack, paginate, paginateItems, refreshUserToken, repostPlaylist, repostTrack, resolveUrl, scFetch, scFetchUrl, searchPlaylists, searchTracks, searchUsers, signOut, unfollowUser, unlikePlaylist, unlikeTrack, unrepostPlaylist, unrepostTrack, updatePlaylist, updateTrack };
2029
+ //# sourceMappingURL=chunk-JH7TLL2C.mjs.map
2030
+ //# sourceMappingURL=chunk-JH7TLL2C.mjs.map