soundcloud-api-ts 1.2.0 → 1.3.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/README.md +51 -0
- package/dist/chunk-34DWTDWF.js +52 -0
- package/dist/chunk-34DWTDWF.js.map +1 -0
- package/dist/chunk-GKNBLKPB.mjs +50 -0
- package/dist/chunk-GKNBLKPB.mjs.map +1 -0
- package/dist/index.d.mts +18 -3
- package/dist/index.d.ts +18 -3
- package/dist/index.js +105 -34
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +102 -34
- package/dist/index.mjs.map +1 -1
- package/dist/types/index.d.mts +60 -1
- package/dist/types/index.d.ts +60 -1
- package/dist/types/index.js +8 -0
- package/dist/types/index.mjs +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -293,6 +293,57 @@ import { getSoundCloudWidgetUrl } from "soundcloud-api-ts";
|
|
|
293
293
|
const widgetUrl = getSoundCloudWidgetUrl(trackId);
|
|
294
294
|
```
|
|
295
295
|
|
|
296
|
+
## Error Handling
|
|
297
|
+
|
|
298
|
+
All API errors throw a `SoundCloudError` with structured properties:
|
|
299
|
+
|
|
300
|
+
```ts
|
|
301
|
+
import { SoundCloudError } from "soundcloud-api-ts";
|
|
302
|
+
|
|
303
|
+
try {
|
|
304
|
+
await sc.tracks.getTrack(999);
|
|
305
|
+
} catch (err) {
|
|
306
|
+
if (err instanceof SoundCloudError) {
|
|
307
|
+
console.log(err.status); // 404
|
|
308
|
+
console.log(err.statusText); // "Not Found"
|
|
309
|
+
console.log(err.message); // "404 - Not Found" (from SC's response)
|
|
310
|
+
console.log(err.errorCode); // "invalid_client" (on auth errors)
|
|
311
|
+
console.log(err.errors); // ["404 - Not Found"] (individual error messages)
|
|
312
|
+
console.log(err.docsLink); // "https://developers.soundcloud.com/docs/api/explorer/open-api"
|
|
313
|
+
console.log(err.body); // full parsed response body
|
|
314
|
+
|
|
315
|
+
// Convenience getters
|
|
316
|
+
if (err.isNotFound) { /* handle 404 */ }
|
|
317
|
+
if (err.isRateLimited) { /* handle 429 */ }
|
|
318
|
+
if (err.isUnauthorized) { /* handle 401 */ }
|
|
319
|
+
if (err.isForbidden) { /* handle 403 */ }
|
|
320
|
+
if (err.isServerError) { /* handle 5xx */ }
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
```
|
|
324
|
+
|
|
325
|
+
Error messages are parsed directly from SoundCloud's API response format, giving you the most useful message available.
|
|
326
|
+
|
|
327
|
+
## Rate Limiting & Retries
|
|
328
|
+
|
|
329
|
+
The client automatically retries on **429 Too Many Requests** and **5xx Server Errors** with exponential backoff:
|
|
330
|
+
|
|
331
|
+
```ts
|
|
332
|
+
const sc = new SoundCloudClient({
|
|
333
|
+
clientId: "...",
|
|
334
|
+
clientSecret: "...",
|
|
335
|
+
maxRetries: 3, // default: 3
|
|
336
|
+
retryBaseDelay: 1000, // default: 1000ms
|
|
337
|
+
onDebug: (msg) => console.log(msg), // optional retry logging
|
|
338
|
+
});
|
|
339
|
+
```
|
|
340
|
+
|
|
341
|
+
- **429 responses** respect the `Retry-After` header when present
|
|
342
|
+
- **5xx responses** (500, 502, 503, 504) are retried with exponential backoff
|
|
343
|
+
- **4xx errors** (except 429) are NOT retried — they throw immediately
|
|
344
|
+
- **401 errors** trigger `onTokenRefresh` (if configured) instead of retry
|
|
345
|
+
- Backoff formula: `baseDelay × 2^attempt` with jitter
|
|
346
|
+
|
|
296
347
|
## Requirements
|
|
297
348
|
|
|
298
349
|
- Node.js 20+ (uses native `fetch`)
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// src/errors.ts
|
|
4
|
+
var SoundCloudError = class extends Error {
|
|
5
|
+
/** HTTP status code */
|
|
6
|
+
status;
|
|
7
|
+
/** HTTP status text (e.g. "Unauthorized") */
|
|
8
|
+
statusText;
|
|
9
|
+
/** SC error code (e.g. "invalid_client") */
|
|
10
|
+
errorCode;
|
|
11
|
+
/** SC docs link */
|
|
12
|
+
docsLink;
|
|
13
|
+
/** Individual error messages from the errors array */
|
|
14
|
+
errors;
|
|
15
|
+
/** The full parsed response body */
|
|
16
|
+
body;
|
|
17
|
+
constructor(status, statusText, body) {
|
|
18
|
+
const msg = body?.message || body?.error_description || body?.error_code || body?.errors?.[0]?.error_message || body?.error || `${status} ${statusText}`;
|
|
19
|
+
super(msg);
|
|
20
|
+
this.name = "SoundCloudError";
|
|
21
|
+
this.status = status;
|
|
22
|
+
this.statusText = statusText;
|
|
23
|
+
this.errorCode = body?.error_code ?? void 0;
|
|
24
|
+
this.docsLink = body?.link ?? void 0;
|
|
25
|
+
this.errors = body?.errors?.map((e) => e.error_message).filter((m) => !!m) ?? [];
|
|
26
|
+
this.body = body;
|
|
27
|
+
}
|
|
28
|
+
/** True if status is 401 Unauthorized */
|
|
29
|
+
get isUnauthorized() {
|
|
30
|
+
return this.status === 401;
|
|
31
|
+
}
|
|
32
|
+
/** True if status is 403 Forbidden */
|
|
33
|
+
get isForbidden() {
|
|
34
|
+
return this.status === 403;
|
|
35
|
+
}
|
|
36
|
+
/** True if status is 404 Not Found */
|
|
37
|
+
get isNotFound() {
|
|
38
|
+
return this.status === 404;
|
|
39
|
+
}
|
|
40
|
+
/** True if status is 429 Too Many Requests */
|
|
41
|
+
get isRateLimited() {
|
|
42
|
+
return this.status === 429;
|
|
43
|
+
}
|
|
44
|
+
/** True if status is 5xx server error */
|
|
45
|
+
get isServerError() {
|
|
46
|
+
return this.status >= 500 && this.status < 600;
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
exports.SoundCloudError = SoundCloudError;
|
|
51
|
+
//# sourceMappingURL=chunk-34DWTDWF.js.map
|
|
52
|
+
//# sourceMappingURL=chunk-34DWTDWF.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/errors.ts"],"names":[],"mappings":";;;AAgCO,IAAM,eAAA,GAAN,cAA8B,KAAA,CAAM;AAAA;AAAA,EAEhC,MAAA;AAAA;AAAA,EAEA,UAAA;AAAA;AAAA,EAEA,SAAA;AAAA;AAAA,EAEA,QAAA;AAAA;AAAA,EAEA,MAAA;AAAA;AAAA,EAEA,IAAA;AAAA,EAET,WAAA,CAAY,MAAA,EAAgB,UAAA,EAAoB,IAAA,EAA4B;AAE1E,IAAA,MAAM,MACJ,IAAA,EAAM,OAAA,IACN,IAAA,EAAM,iBAAA,IACN,MAAM,UAAA,IACN,IAAA,EAAM,MAAA,GAAS,CAAC,GAAG,aAAA,IACnB,IAAA,EAAM,SACN,CAAA,EAAG,MAAM,IAAI,UAAU,CAAA,CAAA;AAEzB,IAAA,KAAA,CAAM,GAAG,CAAA;AACT,IAAA,IAAA,CAAK,IAAA,GAAO,iBAAA;AACZ,IAAA,IAAA,CAAK,MAAA,GAAS,MAAA;AACd,IAAA,IAAA,CAAK,UAAA,GAAa,UAAA;AAClB,IAAA,IAAA,CAAK,SAAA,GAAY,MAAM,UAAA,IAAc,MAAA;AACrC,IAAA,IAAA,CAAK,QAAA,GAAW,MAAM,IAAA,IAAQ,MAAA;AAC9B,IAAA,IAAA,CAAK,SACH,IAAA,EAAM,MAAA,EACF,GAAA,CAAI,CAAC,MAAM,CAAA,CAAE,aAAa,CAAA,CAC3B,MAAA,CAAO,CAAC,CAAA,KAAmB,CAAC,CAAC,CAAC,KAAK,EAAC;AACzC,IAAA,IAAA,CAAK,IAAA,GAAO,IAAA;AAAA,EACd;AAAA;AAAA,EAGA,IAAI,cAAA,GAA0B;AAC5B,IAAA,OAAO,KAAK,MAAA,KAAW,GAAA;AAAA,EACzB;AAAA;AAAA,EAGA,IAAI,WAAA,GAAuB;AACzB,IAAA,OAAO,KAAK,MAAA,KAAW,GAAA;AAAA,EACzB;AAAA;AAAA,EAGA,IAAI,UAAA,GAAsB;AACxB,IAAA,OAAO,KAAK,MAAA,KAAW,GAAA;AAAA,EACzB;AAAA;AAAA,EAGA,IAAI,aAAA,GAAyB;AAC3B,IAAA,OAAO,KAAK,MAAA,KAAW,GAAA;AAAA,EACzB;AAAA;AAAA,EAGA,IAAI,aAAA,GAAyB;AAC3B,IAAA,OAAO,IAAA,CAAK,MAAA,IAAU,GAAA,IAAO,IAAA,CAAK,MAAA,GAAS,GAAA;AAAA,EAC7C;AACF","file":"chunk-34DWTDWF.js","sourcesContent":["/**\n * SoundCloud API error response shape.\n * Based on actual SC API responses, e.g.:\n * {\n * \"code\": 401,\n * \"message\": \"invalid_client\",\n * \"link\": \"https://developers.soundcloud.com/docs/api/explorer/open-api\",\n * \"status\": \"401 - Unauthorized\",\n * \"errors\": [{\"error_message\": \"invalid_client\"}],\n * \"error\": null,\n * \"error_code\": \"invalid_client\"\n * }\n */\nexport interface SoundCloudErrorBody {\n /** HTTP status code */\n code?: number;\n /** Error message from SC (e.g. \"invalid_client\", \"404 - Not Found\") */\n message?: string;\n /** SC status string (e.g. \"401 - Unauthorized\") */\n status?: string;\n /** Link to SC API docs */\n link?: string;\n /** Array of individual errors */\n errors?: Array<{ error_message?: string }>;\n /** Error field — typically null in SC responses */\n error?: string | null;\n /** Error code (e.g. \"invalid_client\") */\n error_code?: string;\n /** OAuth error_description (used in /oauth2/token errors) */\n error_description?: string;\n}\n\nexport class SoundCloudError extends Error {\n /** HTTP status code */\n readonly status: number;\n /** HTTP status text (e.g. \"Unauthorized\") */\n readonly statusText: string;\n /** SC error code (e.g. \"invalid_client\") */\n readonly errorCode?: string;\n /** SC docs link */\n readonly docsLink?: string;\n /** Individual error messages from the errors array */\n readonly errors: string[];\n /** The full parsed response body */\n readonly body?: SoundCloudErrorBody;\n\n constructor(status: number, statusText: string, body?: SoundCloudErrorBody) {\n // Build the most useful message we can from SC's response\n const msg =\n body?.message ||\n body?.error_description ||\n body?.error_code ||\n body?.errors?.[0]?.error_message ||\n body?.error ||\n `${status} ${statusText}`;\n\n super(msg);\n this.name = \"SoundCloudError\";\n this.status = status;\n this.statusText = statusText;\n this.errorCode = body?.error_code ?? undefined;\n this.docsLink = body?.link ?? undefined;\n this.errors =\n body?.errors\n ?.map((e) => e.error_message)\n .filter((m): m is string => !!m) ?? [];\n this.body = body;\n }\n\n /** True if status is 401 Unauthorized */\n get isUnauthorized(): boolean {\n return this.status === 401;\n }\n\n /** True if status is 403 Forbidden */\n get isForbidden(): boolean {\n return this.status === 403;\n }\n\n /** True if status is 404 Not Found */\n get isNotFound(): boolean {\n return this.status === 404;\n }\n\n /** True if status is 429 Too Many Requests */\n get isRateLimited(): boolean {\n return this.status === 429;\n }\n\n /** True if status is 5xx server error */\n get isServerError(): boolean {\n return this.status >= 500 && this.status < 600;\n }\n}\n"]}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// src/errors.ts
|
|
2
|
+
var SoundCloudError = class extends Error {
|
|
3
|
+
/** HTTP status code */
|
|
4
|
+
status;
|
|
5
|
+
/** HTTP status text (e.g. "Unauthorized") */
|
|
6
|
+
statusText;
|
|
7
|
+
/** SC error code (e.g. "invalid_client") */
|
|
8
|
+
errorCode;
|
|
9
|
+
/** SC docs link */
|
|
10
|
+
docsLink;
|
|
11
|
+
/** Individual error messages from the errors array */
|
|
12
|
+
errors;
|
|
13
|
+
/** The full parsed response body */
|
|
14
|
+
body;
|
|
15
|
+
constructor(status, statusText, body) {
|
|
16
|
+
const msg = body?.message || body?.error_description || body?.error_code || body?.errors?.[0]?.error_message || body?.error || `${status} ${statusText}`;
|
|
17
|
+
super(msg);
|
|
18
|
+
this.name = "SoundCloudError";
|
|
19
|
+
this.status = status;
|
|
20
|
+
this.statusText = statusText;
|
|
21
|
+
this.errorCode = body?.error_code ?? void 0;
|
|
22
|
+
this.docsLink = body?.link ?? void 0;
|
|
23
|
+
this.errors = body?.errors?.map((e) => e.error_message).filter((m) => !!m) ?? [];
|
|
24
|
+
this.body = body;
|
|
25
|
+
}
|
|
26
|
+
/** True if status is 401 Unauthorized */
|
|
27
|
+
get isUnauthorized() {
|
|
28
|
+
return this.status === 401;
|
|
29
|
+
}
|
|
30
|
+
/** True if status is 403 Forbidden */
|
|
31
|
+
get isForbidden() {
|
|
32
|
+
return this.status === 403;
|
|
33
|
+
}
|
|
34
|
+
/** True if status is 404 Not Found */
|
|
35
|
+
get isNotFound() {
|
|
36
|
+
return this.status === 404;
|
|
37
|
+
}
|
|
38
|
+
/** True if status is 429 Too Many Requests */
|
|
39
|
+
get isRateLimited() {
|
|
40
|
+
return this.status === 429;
|
|
41
|
+
}
|
|
42
|
+
/** True if status is 5xx server error */
|
|
43
|
+
get isServerError() {
|
|
44
|
+
return this.status >= 500 && this.status < 600;
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
export { SoundCloudError };
|
|
49
|
+
//# sourceMappingURL=chunk-GKNBLKPB.mjs.map
|
|
50
|
+
//# sourceMappingURL=chunk-GKNBLKPB.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/errors.ts"],"names":[],"mappings":";AAgCO,IAAM,eAAA,GAAN,cAA8B,KAAA,CAAM;AAAA;AAAA,EAEhC,MAAA;AAAA;AAAA,EAEA,UAAA;AAAA;AAAA,EAEA,SAAA;AAAA;AAAA,EAEA,QAAA;AAAA;AAAA,EAEA,MAAA;AAAA;AAAA,EAEA,IAAA;AAAA,EAET,WAAA,CAAY,MAAA,EAAgB,UAAA,EAAoB,IAAA,EAA4B;AAE1E,IAAA,MAAM,MACJ,IAAA,EAAM,OAAA,IACN,IAAA,EAAM,iBAAA,IACN,MAAM,UAAA,IACN,IAAA,EAAM,MAAA,GAAS,CAAC,GAAG,aAAA,IACnB,IAAA,EAAM,SACN,CAAA,EAAG,MAAM,IAAI,UAAU,CAAA,CAAA;AAEzB,IAAA,KAAA,CAAM,GAAG,CAAA;AACT,IAAA,IAAA,CAAK,IAAA,GAAO,iBAAA;AACZ,IAAA,IAAA,CAAK,MAAA,GAAS,MAAA;AACd,IAAA,IAAA,CAAK,UAAA,GAAa,UAAA;AAClB,IAAA,IAAA,CAAK,SAAA,GAAY,MAAM,UAAA,IAAc,MAAA;AACrC,IAAA,IAAA,CAAK,QAAA,GAAW,MAAM,IAAA,IAAQ,MAAA;AAC9B,IAAA,IAAA,CAAK,SACH,IAAA,EAAM,MAAA,EACF,GAAA,CAAI,CAAC,MAAM,CAAA,CAAE,aAAa,CAAA,CAC3B,MAAA,CAAO,CAAC,CAAA,KAAmB,CAAC,CAAC,CAAC,KAAK,EAAC;AACzC,IAAA,IAAA,CAAK,IAAA,GAAO,IAAA;AAAA,EACd;AAAA;AAAA,EAGA,IAAI,cAAA,GAA0B;AAC5B,IAAA,OAAO,KAAK,MAAA,KAAW,GAAA;AAAA,EACzB;AAAA;AAAA,EAGA,IAAI,WAAA,GAAuB;AACzB,IAAA,OAAO,KAAK,MAAA,KAAW,GAAA;AAAA,EACzB;AAAA;AAAA,EAGA,IAAI,UAAA,GAAsB;AACxB,IAAA,OAAO,KAAK,MAAA,KAAW,GAAA;AAAA,EACzB;AAAA;AAAA,EAGA,IAAI,aAAA,GAAyB;AAC3B,IAAA,OAAO,KAAK,MAAA,KAAW,GAAA;AAAA,EACzB;AAAA;AAAA,EAGA,IAAI,aAAA,GAAyB;AAC3B,IAAA,OAAO,IAAA,CAAK,MAAA,IAAU,GAAA,IAAO,IAAA,CAAK,MAAA,GAAS,GAAA;AAAA,EAC7C;AACF","file":"chunk-GKNBLKPB.mjs","sourcesContent":["/**\n * SoundCloud API error response shape.\n * Based on actual SC API responses, e.g.:\n * {\n * \"code\": 401,\n * \"message\": \"invalid_client\",\n * \"link\": \"https://developers.soundcloud.com/docs/api/explorer/open-api\",\n * \"status\": \"401 - Unauthorized\",\n * \"errors\": [{\"error_message\": \"invalid_client\"}],\n * \"error\": null,\n * \"error_code\": \"invalid_client\"\n * }\n */\nexport interface SoundCloudErrorBody {\n /** HTTP status code */\n code?: number;\n /** Error message from SC (e.g. \"invalid_client\", \"404 - Not Found\") */\n message?: string;\n /** SC status string (e.g. \"401 - Unauthorized\") */\n status?: string;\n /** Link to SC API docs */\n link?: string;\n /** Array of individual errors */\n errors?: Array<{ error_message?: string }>;\n /** Error field — typically null in SC responses */\n error?: string | null;\n /** Error code (e.g. \"invalid_client\") */\n error_code?: string;\n /** OAuth error_description (used in /oauth2/token errors) */\n error_description?: string;\n}\n\nexport class SoundCloudError extends Error {\n /** HTTP status code */\n readonly status: number;\n /** HTTP status text (e.g. \"Unauthorized\") */\n readonly statusText: string;\n /** SC error code (e.g. \"invalid_client\") */\n readonly errorCode?: string;\n /** SC docs link */\n readonly docsLink?: string;\n /** Individual error messages from the errors array */\n readonly errors: string[];\n /** The full parsed response body */\n readonly body?: SoundCloudErrorBody;\n\n constructor(status: number, statusText: string, body?: SoundCloudErrorBody) {\n // Build the most useful message we can from SC's response\n const msg =\n body?.message ||\n body?.error_description ||\n body?.error_code ||\n body?.errors?.[0]?.error_message ||\n body?.error ||\n `${status} ${statusText}`;\n\n super(msg);\n this.name = \"SoundCloudError\";\n this.status = status;\n this.statusText = statusText;\n this.errorCode = body?.error_code ?? undefined;\n this.docsLink = body?.link ?? undefined;\n this.errors =\n body?.errors\n ?.map((e) => e.error_message)\n .filter((m): m is string => !!m) ?? [];\n this.body = body;\n }\n\n /** True if status is 401 Unauthorized */\n get isUnauthorized(): boolean {\n return this.status === 401;\n }\n\n /** True if status is 403 Forbidden */\n get isForbidden(): boolean {\n return this.status === 403;\n }\n\n /** True if status is 404 Not Found */\n get isNotFound(): boolean {\n return this.status === 404;\n }\n\n /** True if status is 429 Too Many Requests */\n get isRateLimited(): boolean {\n return this.status === 429;\n }\n\n /** True if status is 5xx server error */\n get isServerError(): boolean {\n return this.status >= 500 && this.status < 600;\n }\n}\n"]}
|
package/dist/index.d.mts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { SoundCloudTrack, SoundCloudPlaylist, SoundCloudToken, SoundCloudPaginatedResponse, SoundCloudMe, SoundCloudActivitiesResponse, SoundCloudUser, SoundCloudWebProfile, SoundCloudStreams, SoundCloudComment } from './types/index.mjs';
|
|
2
|
-
export { SoundCloudActivity, SoundCloudCommentUser, SoundCloudQuota, SoundCloudSubscription, SoundCloudSubscriptionProduct } from './types/index.mjs';
|
|
2
|
+
export { SoundCloudActivity, SoundCloudCommentUser, SoundCloudError, SoundCloudQuota, SoundCloudSubscription, SoundCloudSubscriptionProduct } from './types/index.mjs';
|
|
3
3
|
|
|
4
4
|
interface RequestOptions {
|
|
5
5
|
path: string;
|
|
@@ -8,6 +8,14 @@ interface RequestOptions {
|
|
|
8
8
|
body?: Record<string, unknown> | FormData | URLSearchParams;
|
|
9
9
|
contentType?: string;
|
|
10
10
|
}
|
|
11
|
+
interface RetryConfig {
|
|
12
|
+
/** Max retries on 429/5xx (default: 3) */
|
|
13
|
+
maxRetries: number;
|
|
14
|
+
/** Base delay in ms for exponential backoff (default: 1000) */
|
|
15
|
+
retryBaseDelay: number;
|
|
16
|
+
/** Optional debug logger */
|
|
17
|
+
onDebug?: (message: string) => void;
|
|
18
|
+
}
|
|
11
19
|
interface AutoRefreshContext {
|
|
12
20
|
getToken: () => string | undefined;
|
|
13
21
|
onTokenRefresh?: () => Promise<{
|
|
@@ -15,6 +23,7 @@ interface AutoRefreshContext {
|
|
|
15
23
|
refresh_token?: string;
|
|
16
24
|
}>;
|
|
17
25
|
setToken: (accessToken: string, refreshToken?: string) => void;
|
|
26
|
+
retry?: RetryConfig;
|
|
18
27
|
}
|
|
19
28
|
/**
|
|
20
29
|
* Make a request to the SoundCloud API using native fetch.
|
|
@@ -25,7 +34,7 @@ declare function scFetch<T>(options: RequestOptions, refreshCtx?: AutoRefreshCon
|
|
|
25
34
|
* Fetch an absolute URL (e.g. next_href from paginated responses).
|
|
26
35
|
* Adds OAuth token if provided.
|
|
27
36
|
*/
|
|
28
|
-
declare function scFetchUrl<T>(url: string, token?: string): Promise<T>;
|
|
37
|
+
declare function scFetchUrl<T>(url: string, token?: string, retryConfig?: RetryConfig): Promise<T>;
|
|
29
38
|
|
|
30
39
|
interface UpdateTrackParams {
|
|
31
40
|
title?: string;
|
|
@@ -99,6 +108,12 @@ interface SoundCloudClientConfig {
|
|
|
99
108
|
redirectUri?: string;
|
|
100
109
|
/** Called automatically when a request returns 401. Return new tokens to retry. */
|
|
101
110
|
onTokenRefresh?: (client: SoundCloudClient) => Promise<SoundCloudToken>;
|
|
111
|
+
/** Max retries on 429/5xx (default: 3) */
|
|
112
|
+
maxRetries?: number;
|
|
113
|
+
/** Base delay in ms for exponential backoff (default: 1000) */
|
|
114
|
+
retryBaseDelay?: number;
|
|
115
|
+
/** Optional debug logger for retry attempts, etc. */
|
|
116
|
+
onDebug?: (message: string) => void;
|
|
102
117
|
}
|
|
103
118
|
/** Optional token override, passed as the last parameter to client methods. */
|
|
104
119
|
interface TokenOption {
|
|
@@ -487,4 +502,4 @@ declare const unrepostPlaylist: (token: string, playlistId: string | number) =>
|
|
|
487
502
|
*/
|
|
488
503
|
declare const getSoundCloudWidgetUrl: (trackId: string | number) => string;
|
|
489
504
|
|
|
490
|
-
export { type CreatePlaylistParams, type RequestOptions, SoundCloudActivitiesResponse, SoundCloudClient, type SoundCloudClientConfig, SoundCloudComment, SoundCloudMe, SoundCloudPaginatedResponse, SoundCloudPlaylist, SoundCloudStreams, SoundCloudToken, SoundCloudTrack, SoundCloudUser, SoundCloudWebProfile, type TokenOption, type UpdatePlaylistParams, type UpdateTrackParams, 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 };
|
|
505
|
+
export { type CreatePlaylistParams, type RequestOptions, type RetryConfig, SoundCloudActivitiesResponse, SoundCloudClient, type SoundCloudClientConfig, SoundCloudComment, SoundCloudMe, SoundCloudPaginatedResponse, SoundCloudPlaylist, SoundCloudStreams, SoundCloudToken, SoundCloudTrack, SoundCloudUser, SoundCloudWebProfile, type TokenOption, type UpdatePlaylistParams, type UpdateTrackParams, 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 };
|
package/dist/index.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { SoundCloudTrack, SoundCloudPlaylist, SoundCloudToken, SoundCloudPaginatedResponse, SoundCloudMe, SoundCloudActivitiesResponse, SoundCloudUser, SoundCloudWebProfile, SoundCloudStreams, SoundCloudComment } from './types/index.js';
|
|
2
|
-
export { SoundCloudActivity, SoundCloudCommentUser, SoundCloudQuota, SoundCloudSubscription, SoundCloudSubscriptionProduct } from './types/index.js';
|
|
2
|
+
export { SoundCloudActivity, SoundCloudCommentUser, SoundCloudError, SoundCloudQuota, SoundCloudSubscription, SoundCloudSubscriptionProduct } from './types/index.js';
|
|
3
3
|
|
|
4
4
|
interface RequestOptions {
|
|
5
5
|
path: string;
|
|
@@ -8,6 +8,14 @@ interface RequestOptions {
|
|
|
8
8
|
body?: Record<string, unknown> | FormData | URLSearchParams;
|
|
9
9
|
contentType?: string;
|
|
10
10
|
}
|
|
11
|
+
interface RetryConfig {
|
|
12
|
+
/** Max retries on 429/5xx (default: 3) */
|
|
13
|
+
maxRetries: number;
|
|
14
|
+
/** Base delay in ms for exponential backoff (default: 1000) */
|
|
15
|
+
retryBaseDelay: number;
|
|
16
|
+
/** Optional debug logger */
|
|
17
|
+
onDebug?: (message: string) => void;
|
|
18
|
+
}
|
|
11
19
|
interface AutoRefreshContext {
|
|
12
20
|
getToken: () => string | undefined;
|
|
13
21
|
onTokenRefresh?: () => Promise<{
|
|
@@ -15,6 +23,7 @@ interface AutoRefreshContext {
|
|
|
15
23
|
refresh_token?: string;
|
|
16
24
|
}>;
|
|
17
25
|
setToken: (accessToken: string, refreshToken?: string) => void;
|
|
26
|
+
retry?: RetryConfig;
|
|
18
27
|
}
|
|
19
28
|
/**
|
|
20
29
|
* Make a request to the SoundCloud API using native fetch.
|
|
@@ -25,7 +34,7 @@ declare function scFetch<T>(options: RequestOptions, refreshCtx?: AutoRefreshCon
|
|
|
25
34
|
* Fetch an absolute URL (e.g. next_href from paginated responses).
|
|
26
35
|
* Adds OAuth token if provided.
|
|
27
36
|
*/
|
|
28
|
-
declare function scFetchUrl<T>(url: string, token?: string): Promise<T>;
|
|
37
|
+
declare function scFetchUrl<T>(url: string, token?: string, retryConfig?: RetryConfig): Promise<T>;
|
|
29
38
|
|
|
30
39
|
interface UpdateTrackParams {
|
|
31
40
|
title?: string;
|
|
@@ -99,6 +108,12 @@ interface SoundCloudClientConfig {
|
|
|
99
108
|
redirectUri?: string;
|
|
100
109
|
/** Called automatically when a request returns 401. Return new tokens to retry. */
|
|
101
110
|
onTokenRefresh?: (client: SoundCloudClient) => Promise<SoundCloudToken>;
|
|
111
|
+
/** Max retries on 429/5xx (default: 3) */
|
|
112
|
+
maxRetries?: number;
|
|
113
|
+
/** Base delay in ms for exponential backoff (default: 1000) */
|
|
114
|
+
retryBaseDelay?: number;
|
|
115
|
+
/** Optional debug logger for retry attempts, etc. */
|
|
116
|
+
onDebug?: (message: string) => void;
|
|
102
117
|
}
|
|
103
118
|
/** Optional token override, passed as the last parameter to client methods. */
|
|
104
119
|
interface TokenOption {
|
|
@@ -487,4 +502,4 @@ declare const unrepostPlaylist: (token: string, playlistId: string | number) =>
|
|
|
487
502
|
*/
|
|
488
503
|
declare const getSoundCloudWidgetUrl: (trackId: string | number) => string;
|
|
489
504
|
|
|
490
|
-
export { type CreatePlaylistParams, type RequestOptions, SoundCloudActivitiesResponse, SoundCloudClient, type SoundCloudClientConfig, SoundCloudComment, SoundCloudMe, SoundCloudPaginatedResponse, SoundCloudPlaylist, SoundCloudStreams, SoundCloudToken, SoundCloudTrack, SoundCloudUser, SoundCloudWebProfile, type TokenOption, type UpdatePlaylistParams, type UpdateTrackParams, 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 };
|
|
505
|
+
export { type CreatePlaylistParams, type RequestOptions, type RetryConfig, SoundCloudActivitiesResponse, SoundCloudClient, type SoundCloudClientConfig, SoundCloudComment, SoundCloudMe, SoundCloudPaginatedResponse, SoundCloudPlaylist, SoundCloudStreams, SoundCloudToken, SoundCloudTrack, SoundCloudUser, SoundCloudWebProfile, type TokenOption, type UpdatePlaylistParams, type UpdateTrackParams, 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 };
|
package/dist/index.js
CHANGED
|
@@ -1,8 +1,36 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
+
var chunk34DWTDWF_js = require('./chunk-34DWTDWF.js');
|
|
4
|
+
|
|
3
5
|
// src/client/http.ts
|
|
4
6
|
var BASE_URL = "https://api.soundcloud.com";
|
|
7
|
+
var DEFAULT_RETRY = { maxRetries: 3, retryBaseDelay: 1e3 };
|
|
8
|
+
function delay(ms) {
|
|
9
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
10
|
+
}
|
|
11
|
+
function isRetryable(status) {
|
|
12
|
+
return status === 429 || status >= 500 && status <= 599;
|
|
13
|
+
}
|
|
14
|
+
function getRetryDelay(response, attempt, config) {
|
|
15
|
+
if (response.status === 429) {
|
|
16
|
+
const retryAfter = response.headers.get("retry-after");
|
|
17
|
+
if (retryAfter) {
|
|
18
|
+
const seconds = Number(retryAfter);
|
|
19
|
+
if (!Number.isNaN(seconds)) return seconds * 1e3;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
const base = config.retryBaseDelay * Math.pow(2, attempt);
|
|
23
|
+
return base + Math.random() * base * 0.1;
|
|
24
|
+
}
|
|
25
|
+
async function parseErrorBody(response) {
|
|
26
|
+
try {
|
|
27
|
+
return await response.json();
|
|
28
|
+
} catch {
|
|
29
|
+
return void 0;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
5
32
|
async function scFetch(options, refreshCtx) {
|
|
33
|
+
const retryConfig = refreshCtx?.retry ?? DEFAULT_RETRY;
|
|
6
34
|
const execute = async (tokenOverride) => {
|
|
7
35
|
const url = `${BASE_URL}${options.path}`;
|
|
8
36
|
const headers = {
|
|
@@ -26,32 +54,44 @@ async function scFetch(options, refreshCtx) {
|
|
|
26
54
|
} else if (options.contentType) {
|
|
27
55
|
headers["Content-Type"] = options.contentType;
|
|
28
56
|
}
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
if (
|
|
38
|
-
|
|
57
|
+
let lastResponse;
|
|
58
|
+
for (let attempt = 0; attempt <= retryConfig.maxRetries; attempt++) {
|
|
59
|
+
const response = await fetch(url, {
|
|
60
|
+
method: options.method,
|
|
61
|
+
headers,
|
|
62
|
+
body: fetchBody,
|
|
63
|
+
redirect: "manual"
|
|
64
|
+
});
|
|
65
|
+
if (response.status === 302) {
|
|
66
|
+
const location = response.headers.get("location");
|
|
67
|
+
if (location) return location;
|
|
68
|
+
}
|
|
69
|
+
if (response.status === 204 || response.headers.get("content-length") === "0") {
|
|
70
|
+
return void 0;
|
|
71
|
+
}
|
|
72
|
+
if (response.ok) {
|
|
73
|
+
return response.json();
|
|
74
|
+
}
|
|
75
|
+
if (!isRetryable(response.status)) {
|
|
76
|
+
const body2 = await parseErrorBody(response);
|
|
77
|
+
throw new chunk34DWTDWF_js.SoundCloudError(response.status, response.statusText, body2);
|
|
78
|
+
}
|
|
79
|
+
lastResponse = response;
|
|
80
|
+
if (attempt < retryConfig.maxRetries) {
|
|
81
|
+
const delayMs = getRetryDelay(response, attempt, retryConfig);
|
|
82
|
+
retryConfig.onDebug?.(
|
|
83
|
+
`Retry ${attempt + 1}/${retryConfig.maxRetries} after ${Math.round(delayMs)}ms (status ${response.status})`
|
|
84
|
+
);
|
|
85
|
+
await delay(delayMs);
|
|
39
86
|
}
|
|
40
87
|
}
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
}
|
|
44
|
-
if (!response.ok) {
|
|
45
|
-
throw new Error(
|
|
46
|
-
`SoundCloud API error: ${response.status} ${response.statusText}`
|
|
47
|
-
);
|
|
48
|
-
}
|
|
49
|
-
return response.json();
|
|
88
|
+
const body = await parseErrorBody(lastResponse);
|
|
89
|
+
throw new chunk34DWTDWF_js.SoundCloudError(lastResponse.status, lastResponse.statusText, body);
|
|
50
90
|
};
|
|
51
91
|
try {
|
|
52
92
|
return await execute();
|
|
53
93
|
} catch (err) {
|
|
54
|
-
if (refreshCtx?.onTokenRefresh && err instanceof
|
|
94
|
+
if (refreshCtx?.onTokenRefresh && err instanceof chunk34DWTDWF_js.SoundCloudError && err.status === 401) {
|
|
55
95
|
const newToken = await refreshCtx.onTokenRefresh();
|
|
56
96
|
refreshCtx.setToken(newToken.access_token, newToken.refresh_token);
|
|
57
97
|
return execute(newToken.access_token);
|
|
@@ -59,21 +99,38 @@ async function scFetch(options, refreshCtx) {
|
|
|
59
99
|
throw err;
|
|
60
100
|
}
|
|
61
101
|
}
|
|
62
|
-
async function scFetchUrl(url, token) {
|
|
102
|
+
async function scFetchUrl(url, token, retryConfig) {
|
|
103
|
+
const config = retryConfig ?? DEFAULT_RETRY;
|
|
63
104
|
const headers = { Accept: "application/json" };
|
|
64
105
|
if (token) headers["Authorization"] = `OAuth ${token}`;
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
const
|
|
68
|
-
if (
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
106
|
+
let lastResponse;
|
|
107
|
+
for (let attempt = 0; attempt <= config.maxRetries; attempt++) {
|
|
108
|
+
const response = await fetch(url, { method: "GET", headers, redirect: "manual" });
|
|
109
|
+
if (response.status === 302) {
|
|
110
|
+
const location = response.headers.get("location");
|
|
111
|
+
if (location) return location;
|
|
112
|
+
}
|
|
113
|
+
if (response.status === 204 || response.headers.get("content-length") === "0") {
|
|
114
|
+
return void 0;
|
|
115
|
+
}
|
|
116
|
+
if (response.ok) {
|
|
117
|
+
return response.json();
|
|
118
|
+
}
|
|
119
|
+
if (!isRetryable(response.status)) {
|
|
120
|
+
const body2 = await parseErrorBody(response);
|
|
121
|
+
throw new chunk34DWTDWF_js.SoundCloudError(response.status, response.statusText, body2);
|
|
122
|
+
}
|
|
123
|
+
lastResponse = response;
|
|
124
|
+
if (attempt < config.maxRetries) {
|
|
125
|
+
const delayMs = getRetryDelay(response, attempt, config);
|
|
126
|
+
config.onDebug?.(
|
|
127
|
+
`Retry ${attempt + 1}/${config.maxRetries} after ${Math.round(delayMs)}ms (status ${response.status})`
|
|
128
|
+
);
|
|
129
|
+
await delay(delayMs);
|
|
130
|
+
}
|
|
75
131
|
}
|
|
76
|
-
|
|
132
|
+
const body = await parseErrorBody(lastResponse);
|
|
133
|
+
throw new chunk34DWTDWF_js.SoundCloudError(lastResponse.status, lastResponse.statusText, body);
|
|
77
134
|
}
|
|
78
135
|
|
|
79
136
|
// src/client/paginate.ts
|
|
@@ -126,14 +183,24 @@ exports.SoundCloudClient = class _SoundCloudClient {
|
|
|
126
183
|
constructor(config) {
|
|
127
184
|
this.config = config;
|
|
128
185
|
const getToken = () => this._accessToken;
|
|
186
|
+
const retryConfig = {
|
|
187
|
+
maxRetries: config.maxRetries ?? 3,
|
|
188
|
+
retryBaseDelay: config.retryBaseDelay ?? 1e3,
|
|
189
|
+
onDebug: config.onDebug
|
|
190
|
+
};
|
|
129
191
|
const refreshCtx = config.onTokenRefresh ? {
|
|
130
192
|
getToken,
|
|
131
193
|
onTokenRefresh: async () => {
|
|
132
194
|
const result = await config.onTokenRefresh(this);
|
|
133
195
|
return result;
|
|
134
196
|
},
|
|
135
|
-
setToken: (a, r) => this.setToken(a, r)
|
|
136
|
-
|
|
197
|
+
setToken: (a, r) => this.setToken(a, r),
|
|
198
|
+
retry: retryConfig
|
|
199
|
+
} : {
|
|
200
|
+
getToken,
|
|
201
|
+
setToken: (a, r) => this.setToken(a, r),
|
|
202
|
+
retry: retryConfig
|
|
203
|
+
};
|
|
137
204
|
this.auth = new _SoundCloudClient.Auth(this.config);
|
|
138
205
|
this.me = new _SoundCloudClient.Me(getToken, refreshCtx);
|
|
139
206
|
this.users = new _SoundCloudClient.Users(getToken, refreshCtx);
|
|
@@ -935,6 +1002,10 @@ var unrepostPlaylist = async (token, playlistId) => {
|
|
|
935
1002
|
// src/utils/widget.ts
|
|
936
1003
|
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`;
|
|
937
1004
|
|
|
1005
|
+
Object.defineProperty(exports, "SoundCloudError", {
|
|
1006
|
+
enumerable: true,
|
|
1007
|
+
get: function () { return chunk34DWTDWF_js.SoundCloudError; }
|
|
1008
|
+
});
|
|
938
1009
|
exports.createPlaylist = createPlaylist;
|
|
939
1010
|
exports.createTrackComment = createTrackComment;
|
|
940
1011
|
exports.deletePlaylist = deletePlaylist;
|