soundcloud-api-ts 1.10.1 → 1.11.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
@@ -125,9 +125,10 @@ try {
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
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`).
127
127
  4. **Auto token refresh** — pass `onTokenRefresh` in the config to automatically refresh expired tokens on 401.
128
- 5. **No env vars** — the package reads no environment variables. Pass `clientId`, `clientSecret`, and `redirectUri` directly to the constructor.
129
- 6. **IDs can be numbers or strings** all ID parameters accept `string | number`.
130
- 7. **Search pagination** search uses zero-based `pageNumber` (10 results per page), not cursor-based pagination.
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. **No env vars** the package reads no environment variables. Pass `clientId`, `clientSecret`, and `redirectUri` directly to the constructor.
130
+ 7. **IDs can be numbers or strings** all ID parameters accept `string | number`.
131
+ 8. **Search pagination** — search uses zero-based `pageNumber` (10 results per page), not cursor-based pagination.
131
132
 
132
133
  ## Project Structure (for contributors)
133
134
 
package/README.md CHANGED
@@ -423,6 +423,35 @@ const sc = new SoundCloudClient({
423
423
  - **401 errors** trigger `onTokenRefresh` (if configured) instead of retry
424
424
  - Backoff formula: `baseDelay × 2^attempt` with jitter
425
425
 
426
+ ## Request Telemetry
427
+
428
+ Hook into every API request for logging, metrics, or observability:
429
+
430
+ ```ts
431
+ import { SoundCloudClient, type SCRequestTelemetry } from 'soundcloud-api-ts';
432
+
433
+ const sc = new SoundCloudClient({
434
+ clientId: '...',
435
+ clientSecret: '...',
436
+ onRequest: (t: SCRequestTelemetry) => {
437
+ console.log(`[SC] ${t.method} ${t.path} status=${t.status} ${t.durationMs}ms retries=${t.retryCount}`);
438
+ },
439
+ });
440
+ ```
441
+
442
+ The `SCRequestTelemetry` object includes:
443
+
444
+ | Field | Type | Description |
445
+ |---|---|---|
446
+ | `method` | `"GET" \| "POST" \| "PUT" \| "DELETE"` | HTTP method |
447
+ | `path` | `string` | API path or full URL (for pagination) |
448
+ | `durationMs` | `number` | Total wall-clock time including retries |
449
+ | `status` | `number` | Final HTTP status code |
450
+ | `retryCount` | `number` | Number of retries (0 = first attempt succeeded) |
451
+ | `error` | `string?` | Error message if the request failed |
452
+
453
+ Telemetry fires on every code path: direct calls, pagination, retries, and 401 token refresh. It's fully optional — zero overhead when `onRequest` is not set.
454
+
426
455
  ## API Terms Compliance
427
456
 
428
457
  This package is built on SoundCloud's **official documented API** (`api.soundcloud.com`) and follows the [API Terms of Use](https://developers.soundcloud.com/docs/api/terms-of-use):
@@ -28,16 +28,32 @@ async function parseErrorBody(response) {
28
28
  return void 0;
29
29
  }
30
30
  }
31
- async function scFetch(options, refreshCtx) {
31
+ async function scFetch(options, refreshCtx, onRequest) {
32
32
  const retryConfig = refreshCtx?.retry ?? DEFAULT_RETRY;
33
+ const telemetryCallback = onRequest ?? refreshCtx?.onRequest;
34
+ const startTime = Date.now();
35
+ let retryCount = 0;
36
+ let finalStatus = 0;
37
+ const emitTelemetry = (error) => {
38
+ if (!telemetryCallback) return;
39
+ telemetryCallback({
40
+ method: options.method,
41
+ path: options.path,
42
+ durationMs: Date.now() - startTime,
43
+ status: finalStatus,
44
+ retryCount,
45
+ ...error ? { error } : {}
46
+ });
47
+ };
33
48
  const execute = async (tokenOverride) => {
34
49
  const isAuthPath = options.path.startsWith("/oauth");
35
50
  const url = `${isAuthPath ? AUTH_BASE_URL : BASE_URL}${options.path}`;
36
51
  const headers = {
37
- Accept: "application/json"
52
+ Accept: "application/json",
53
+ ...options.headers
38
54
  };
39
55
  const token = tokenOverride ?? options.token;
40
- if (token) {
56
+ if (token && !headers["Authorization"]) {
41
57
  headers["Authorization"] = `OAuth ${token}`;
42
58
  }
43
59
  let fetchBody;
@@ -62,22 +78,32 @@ async function scFetch(options, refreshCtx) {
62
78
  body: fetchBody,
63
79
  redirect: "manual"
64
80
  });
81
+ finalStatus = response.status;
65
82
  if (response.status === 302) {
66
83
  const location = response.headers.get("location");
67
- if (location) return location;
84
+ if (location) {
85
+ emitTelemetry();
86
+ return location;
87
+ }
68
88
  }
69
89
  if (response.status === 204 || response.headers.get("content-length") === "0") {
90
+ emitTelemetry();
70
91
  return void 0;
71
92
  }
72
93
  if (response.ok) {
73
- return response.json();
94
+ const result = response.json();
95
+ emitTelemetry();
96
+ return result;
74
97
  }
75
98
  if (!isRetryable(response.status)) {
76
99
  const body2 = await parseErrorBody(response);
77
- throw new SoundCloudError(response.status, response.statusText, body2);
100
+ const err2 = new SoundCloudError(response.status, response.statusText, body2);
101
+ emitTelemetry(err2.message);
102
+ throw err2;
78
103
  }
79
104
  lastResponse = response;
80
105
  if (attempt < retryConfig.maxRetries) {
106
+ retryCount = attempt + 1;
81
107
  const delayMs = getRetryDelay(response, attempt, retryConfig);
82
108
  retryConfig.onDebug?.(
83
109
  `Retry ${attempt + 1}/${retryConfig.maxRetries} after ${Math.round(delayMs)}ms (status ${response.status})`
@@ -86,7 +112,9 @@ async function scFetch(options, refreshCtx) {
86
112
  }
87
113
  }
88
114
  const body = await parseErrorBody(lastResponse);
89
- throw new SoundCloudError(lastResponse.status, lastResponse.statusText, body);
115
+ const err = new SoundCloudError(lastResponse.status, lastResponse.statusText, body);
116
+ emitTelemetry(err.message);
117
+ throw err;
90
118
  };
91
119
  try {
92
120
  return await execute();
@@ -99,29 +127,53 @@ async function scFetch(options, refreshCtx) {
99
127
  throw err;
100
128
  }
101
129
  }
102
- async function scFetchUrl(url, token, retryConfig) {
130
+ async function scFetchUrl(url, token, retryConfig, onRequest) {
103
131
  const config = retryConfig ?? DEFAULT_RETRY;
104
132
  const headers = { Accept: "application/json" };
105
133
  if (token) headers["Authorization"] = `OAuth ${token}`;
134
+ const startTime = Date.now();
135
+ let retryCount = 0;
136
+ let finalStatus = 0;
137
+ const emitTelemetry = (error) => {
138
+ if (!onRequest) return;
139
+ onRequest({
140
+ method: "GET",
141
+ path: url,
142
+ durationMs: Date.now() - startTime,
143
+ status: finalStatus,
144
+ retryCount,
145
+ ...error ? { error } : {}
146
+ });
147
+ };
106
148
  let lastResponse;
107
149
  for (let attempt = 0; attempt <= config.maxRetries; attempt++) {
108
150
  const response = await fetch(url, { method: "GET", headers, redirect: "manual" });
151
+ finalStatus = response.status;
109
152
  if (response.status === 302) {
110
153
  const location = response.headers.get("location");
111
- if (location) return location;
154
+ if (location) {
155
+ emitTelemetry();
156
+ return location;
157
+ }
112
158
  }
113
159
  if (response.status === 204 || response.headers.get("content-length") === "0") {
160
+ emitTelemetry();
114
161
  return void 0;
115
162
  }
116
163
  if (response.ok) {
117
- return response.json();
164
+ const result = response.json();
165
+ emitTelemetry();
166
+ return result;
118
167
  }
119
168
  if (!isRetryable(response.status)) {
120
169
  const body2 = await parseErrorBody(response);
121
- throw new SoundCloudError(response.status, response.statusText, body2);
170
+ const err2 = new SoundCloudError(response.status, response.statusText, body2);
171
+ emitTelemetry(err2.message);
172
+ throw err2;
122
173
  }
123
174
  lastResponse = response;
124
175
  if (attempt < config.maxRetries) {
176
+ retryCount = attempt + 1;
125
177
  const delayMs = getRetryDelay(response, attempt, config);
126
178
  config.onDebug?.(
127
179
  `Retry ${attempt + 1}/${config.maxRetries} after ${Math.round(delayMs)}ms (status ${response.status})`
@@ -130,7 +182,9 @@ async function scFetchUrl(url, token, retryConfig) {
130
182
  }
131
183
  }
132
184
  const body = await parseErrorBody(lastResponse);
133
- throw new SoundCloudError(lastResponse.status, lastResponse.statusText, body);
185
+ const err = new SoundCloudError(lastResponse.status, lastResponse.statusText, body);
186
+ emitTelemetry(err.message);
187
+ throw err;
134
188
  }
135
189
 
136
190
  // src/client/paginate.ts
@@ -209,14 +263,16 @@ var SoundCloudClient = class _SoundCloudClient {
209
263
  return result;
210
264
  },
211
265
  setToken: (a, r) => this.setToken(a, r),
212
- retry: retryConfig
266
+ retry: retryConfig,
267
+ onRequest: config.onRequest
213
268
  } : {
214
269
  getToken,
215
270
  setToken: (
216
271
  /* v8 ignore next */
217
272
  (a, r) => this.setToken(a, r)
218
273
  ),
219
- retry: retryConfig
274
+ retry: retryConfig,
275
+ onRequest: config.onRequest
220
276
  };
221
277
  this.auth = new _SoundCloudClient.Auth(this.config);
222
278
  this.me = new _SoundCloudClient.Me(getToken, refreshCtx);
@@ -266,7 +322,8 @@ var SoundCloudClient = class _SoundCloudClient {
266
322
  */
267
323
  paginate(firstPage) {
268
324
  const token = this._accessToken;
269
- return paginate(firstPage, (url) => scFetchUrl(url, token));
325
+ const onReq = this.config.onRequest;
326
+ return paginate(firstPage, (url) => scFetchUrl(url, token, void 0, onReq));
270
327
  }
271
328
  /**
272
329
  * Async generator that yields individual items across all pages.
@@ -283,7 +340,8 @@ var SoundCloudClient = class _SoundCloudClient {
283
340
  */
284
341
  paginateItems(firstPage) {
285
342
  const token = this._accessToken;
286
- return paginateItems(firstPage, (url) => scFetchUrl(url, token));
343
+ const onReq = this.config.onRequest;
344
+ return paginateItems(firstPage, (url) => scFetchUrl(url, token, void 0, onReq));
287
345
  }
288
346
  /**
289
347
  * Collects all pages into a single flat array.
@@ -301,7 +359,8 @@ var SoundCloudClient = class _SoundCloudClient {
301
359
  */
302
360
  fetchAll(firstPage, options) {
303
361
  const token = this._accessToken;
304
- return fetchAll(firstPage, (url) => scFetchUrl(url, token), options);
362
+ const onReq = this.config.onRequest;
363
+ return fetchAll(firstPage, (url) => scFetchUrl(url, token, void 0, onReq), options);
305
364
  }
306
365
  };
307
366
  ((SoundCloudClient2) => {
@@ -1392,13 +1451,15 @@ var SoundCloudClient = class _SoundCloudClient {
1392
1451
 
1393
1452
  // src/auth/getClientToken.ts
1394
1453
  var getClientToken = (clientId, clientSecret) => {
1454
+ const basicAuth = Buffer.from(`${clientId}:${clientSecret}`).toString("base64");
1395
1455
  return scFetch({
1396
1456
  path: "/oauth/token",
1397
1457
  method: "POST",
1458
+ headers: {
1459
+ Authorization: `Basic ${basicAuth}`
1460
+ },
1398
1461
  body: new URLSearchParams({
1399
- grant_type: "client_credentials",
1400
- client_id: clientId,
1401
- client_secret: clientSecret
1462
+ grant_type: "client_credentials"
1402
1463
  })
1403
1464
  });
1404
1465
  };
@@ -1681,5 +1742,5 @@ var unrepostPlaylist = async (token, playlistId) => {
1681
1742
  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`;
1682
1743
 
1683
1744
  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 };
1684
- //# sourceMappingURL=chunk-RETVFKZM.mjs.map
1685
- //# sourceMappingURL=chunk-RETVFKZM.mjs.map
1745
+ //# sourceMappingURL=chunk-5FCXAR2S.mjs.map
1746
+ //# sourceMappingURL=chunk-5FCXAR2S.mjs.map