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 +4 -3
- package/README.md +29 -0
- package/dist/{chunk-RETVFKZM.mjs → chunk-5FCXAR2S.mjs} +83 -22
- package/dist/chunk-5FCXAR2S.mjs.map +1 -0
- package/dist/{chunk-YKKLNC4A.js → chunk-7IRQD552.js} +83 -22
- 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 +26 -3
- package/dist/index.d.ts +26 -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-RETVFKZM.mjs.map +0 -1
- package/dist/chunk-YKKLNC4A.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,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)
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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-
|
|
1685
|
-
//# sourceMappingURL=chunk-
|
|
1745
|
+
//# sourceMappingURL=chunk-5FCXAR2S.mjs.map
|
|
1746
|
+
//# sourceMappingURL=chunk-5FCXAR2S.mjs.map
|