soundcloud-api-ts 1.10.2 → 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 +4 -3
- package/README.md +29 -0
- package/dist/{chunk-ACK4KMGD.mjs → chunk-5FCXAR2S.mjs} +75 -17
- package/dist/chunk-5FCXAR2S.mjs.map +1 -0
- package/dist/{chunk-J2PMIV3Z.js → chunk-7IRQD552.js} +75 -17
- package/dist/chunk-7IRQD552.js.map +1 -0
- package/dist/cli.js +6 -6
- package/dist/cli.mjs +1 -1
- package/dist/index.d.mts +24 -3
- package/dist/index.d.ts +24 -3
- package/dist/index.js +63 -63
- package/dist/index.mjs +1 -1
- package/llms.txt +11 -1
- package/package.json +1 -1
- package/dist/chunk-ACK4KMGD.mjs.map +0 -1
- package/dist/chunk-J2PMIV3Z.js.map +0 -1
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. **
|
|
129
|
-
6. **
|
|
130
|
-
7. **
|
|
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,8 +28,23 @@ 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}`;
|
|
@@ -63,22 +78,32 @@ async function scFetch(options, refreshCtx) {
|
|
|
63
78
|
body: fetchBody,
|
|
64
79
|
redirect: "manual"
|
|
65
80
|
});
|
|
81
|
+
finalStatus = response.status;
|
|
66
82
|
if (response.status === 302) {
|
|
67
83
|
const location = response.headers.get("location");
|
|
68
|
-
if (location)
|
|
84
|
+
if (location) {
|
|
85
|
+
emitTelemetry();
|
|
86
|
+
return location;
|
|
87
|
+
}
|
|
69
88
|
}
|
|
70
89
|
if (response.status === 204 || response.headers.get("content-length") === "0") {
|
|
90
|
+
emitTelemetry();
|
|
71
91
|
return void 0;
|
|
72
92
|
}
|
|
73
93
|
if (response.ok) {
|
|
74
|
-
|
|
94
|
+
const result = response.json();
|
|
95
|
+
emitTelemetry();
|
|
96
|
+
return result;
|
|
75
97
|
}
|
|
76
98
|
if (!isRetryable(response.status)) {
|
|
77
99
|
const body2 = await parseErrorBody(response);
|
|
78
|
-
|
|
100
|
+
const err2 = new SoundCloudError(response.status, response.statusText, body2);
|
|
101
|
+
emitTelemetry(err2.message);
|
|
102
|
+
throw err2;
|
|
79
103
|
}
|
|
80
104
|
lastResponse = response;
|
|
81
105
|
if (attempt < retryConfig.maxRetries) {
|
|
106
|
+
retryCount = attempt + 1;
|
|
82
107
|
const delayMs = getRetryDelay(response, attempt, retryConfig);
|
|
83
108
|
retryConfig.onDebug?.(
|
|
84
109
|
`Retry ${attempt + 1}/${retryConfig.maxRetries} after ${Math.round(delayMs)}ms (status ${response.status})`
|
|
@@ -87,7 +112,9 @@ async function scFetch(options, refreshCtx) {
|
|
|
87
112
|
}
|
|
88
113
|
}
|
|
89
114
|
const body = await parseErrorBody(lastResponse);
|
|
90
|
-
|
|
115
|
+
const err = new SoundCloudError(lastResponse.status, lastResponse.statusText, body);
|
|
116
|
+
emitTelemetry(err.message);
|
|
117
|
+
throw err;
|
|
91
118
|
};
|
|
92
119
|
try {
|
|
93
120
|
return await execute();
|
|
@@ -100,29 +127,53 @@ async function scFetch(options, refreshCtx) {
|
|
|
100
127
|
throw err;
|
|
101
128
|
}
|
|
102
129
|
}
|
|
103
|
-
async function scFetchUrl(url, token, retryConfig) {
|
|
130
|
+
async function scFetchUrl(url, token, retryConfig, onRequest) {
|
|
104
131
|
const config = retryConfig ?? DEFAULT_RETRY;
|
|
105
132
|
const headers = { Accept: "application/json" };
|
|
106
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
|
+
};
|
|
107
148
|
let lastResponse;
|
|
108
149
|
for (let attempt = 0; attempt <= config.maxRetries; attempt++) {
|
|
109
150
|
const response = await fetch(url, { method: "GET", headers, redirect: "manual" });
|
|
151
|
+
finalStatus = response.status;
|
|
110
152
|
if (response.status === 302) {
|
|
111
153
|
const location = response.headers.get("location");
|
|
112
|
-
if (location)
|
|
154
|
+
if (location) {
|
|
155
|
+
emitTelemetry();
|
|
156
|
+
return location;
|
|
157
|
+
}
|
|
113
158
|
}
|
|
114
159
|
if (response.status === 204 || response.headers.get("content-length") === "0") {
|
|
160
|
+
emitTelemetry();
|
|
115
161
|
return void 0;
|
|
116
162
|
}
|
|
117
163
|
if (response.ok) {
|
|
118
|
-
|
|
164
|
+
const result = response.json();
|
|
165
|
+
emitTelemetry();
|
|
166
|
+
return result;
|
|
119
167
|
}
|
|
120
168
|
if (!isRetryable(response.status)) {
|
|
121
169
|
const body2 = await parseErrorBody(response);
|
|
122
|
-
|
|
170
|
+
const err2 = new SoundCloudError(response.status, response.statusText, body2);
|
|
171
|
+
emitTelemetry(err2.message);
|
|
172
|
+
throw err2;
|
|
123
173
|
}
|
|
124
174
|
lastResponse = response;
|
|
125
175
|
if (attempt < config.maxRetries) {
|
|
176
|
+
retryCount = attempt + 1;
|
|
126
177
|
const delayMs = getRetryDelay(response, attempt, config);
|
|
127
178
|
config.onDebug?.(
|
|
128
179
|
`Retry ${attempt + 1}/${config.maxRetries} after ${Math.round(delayMs)}ms (status ${response.status})`
|
|
@@ -131,7 +182,9 @@ async function scFetchUrl(url, token, retryConfig) {
|
|
|
131
182
|
}
|
|
132
183
|
}
|
|
133
184
|
const body = await parseErrorBody(lastResponse);
|
|
134
|
-
|
|
185
|
+
const err = new SoundCloudError(lastResponse.status, lastResponse.statusText, body);
|
|
186
|
+
emitTelemetry(err.message);
|
|
187
|
+
throw err;
|
|
135
188
|
}
|
|
136
189
|
|
|
137
190
|
// src/client/paginate.ts
|
|
@@ -210,14 +263,16 @@ var SoundCloudClient = class _SoundCloudClient {
|
|
|
210
263
|
return result;
|
|
211
264
|
},
|
|
212
265
|
setToken: (a, r) => this.setToken(a, r),
|
|
213
|
-
retry: retryConfig
|
|
266
|
+
retry: retryConfig,
|
|
267
|
+
onRequest: config.onRequest
|
|
214
268
|
} : {
|
|
215
269
|
getToken,
|
|
216
270
|
setToken: (
|
|
217
271
|
/* v8 ignore next */
|
|
218
272
|
(a, r) => this.setToken(a, r)
|
|
219
273
|
),
|
|
220
|
-
retry: retryConfig
|
|
274
|
+
retry: retryConfig,
|
|
275
|
+
onRequest: config.onRequest
|
|
221
276
|
};
|
|
222
277
|
this.auth = new _SoundCloudClient.Auth(this.config);
|
|
223
278
|
this.me = new _SoundCloudClient.Me(getToken, refreshCtx);
|
|
@@ -267,7 +322,8 @@ var SoundCloudClient = class _SoundCloudClient {
|
|
|
267
322
|
*/
|
|
268
323
|
paginate(firstPage) {
|
|
269
324
|
const token = this._accessToken;
|
|
270
|
-
|
|
325
|
+
const onReq = this.config.onRequest;
|
|
326
|
+
return paginate(firstPage, (url) => scFetchUrl(url, token, void 0, onReq));
|
|
271
327
|
}
|
|
272
328
|
/**
|
|
273
329
|
* Async generator that yields individual items across all pages.
|
|
@@ -284,7 +340,8 @@ var SoundCloudClient = class _SoundCloudClient {
|
|
|
284
340
|
*/
|
|
285
341
|
paginateItems(firstPage) {
|
|
286
342
|
const token = this._accessToken;
|
|
287
|
-
|
|
343
|
+
const onReq = this.config.onRequest;
|
|
344
|
+
return paginateItems(firstPage, (url) => scFetchUrl(url, token, void 0, onReq));
|
|
288
345
|
}
|
|
289
346
|
/**
|
|
290
347
|
* Collects all pages into a single flat array.
|
|
@@ -302,7 +359,8 @@ var SoundCloudClient = class _SoundCloudClient {
|
|
|
302
359
|
*/
|
|
303
360
|
fetchAll(firstPage, options) {
|
|
304
361
|
const token = this._accessToken;
|
|
305
|
-
|
|
362
|
+
const onReq = this.config.onRequest;
|
|
363
|
+
return fetchAll(firstPage, (url) => scFetchUrl(url, token, void 0, onReq), options);
|
|
306
364
|
}
|
|
307
365
|
};
|
|
308
366
|
((SoundCloudClient2) => {
|
|
@@ -1684,5 +1742,5 @@ var unrepostPlaylist = async (token, playlistId) => {
|
|
|
1684
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`;
|
|
1685
1743
|
|
|
1686
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 };
|
|
1687
|
-
//# sourceMappingURL=chunk-
|
|
1688
|
-
//# sourceMappingURL=chunk-
|
|
1745
|
+
//# sourceMappingURL=chunk-5FCXAR2S.mjs.map
|
|
1746
|
+
//# sourceMappingURL=chunk-5FCXAR2S.mjs.map
|