@zibot/scdl 0.0.4 → 0.0.6
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/.prettierrc +21 -0
- package/README.md +272 -55
- package/index.d.ts +10 -5
- package/index.js +219 -82
- package/package.json +32 -35
package/.prettierrc
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
{
|
|
2
|
+
"arrowParens": "always",
|
|
3
|
+
"bracketSameLine": true,
|
|
4
|
+
"bracketSpacing": true,
|
|
5
|
+
"semi": true,
|
|
6
|
+
"experimentalTernaries": true,
|
|
7
|
+
"singleQuote": false,
|
|
8
|
+
"jsxSingleQuote": true,
|
|
9
|
+
"quoteProps": "as-needed",
|
|
10
|
+
"trailingComma": "all",
|
|
11
|
+
"singleAttributePerLine": true,
|
|
12
|
+
"htmlWhitespaceSensitivity": "css",
|
|
13
|
+
"vueIndentScriptAndStyle": false,
|
|
14
|
+
"proseWrap": "always",
|
|
15
|
+
"insertPragma": false,
|
|
16
|
+
"printWidth": 130,
|
|
17
|
+
"requirePragma": false,
|
|
18
|
+
"tabWidth": 2,
|
|
19
|
+
"useTabs": true,
|
|
20
|
+
"embeddedLanguageFormatting": "auto"
|
|
21
|
+
}
|
package/README.md
CHANGED
|
@@ -1,118 +1,335 @@
|
|
|
1
1
|
# @zibot/scdl
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
phương thức tiện lợi để xử lý track, playlist, và tải nội dung.
|
|
3
|
+
A tiny, promise-based Node.js client for searching SoundCloud, fetching track/playlist details, and downloading tracks as readable streams.
|
|
5
4
|
|
|
6
|
-
|
|
5
|
+
* **Search** tracks, playlists, and users
|
|
6
|
+
* **Inspect** rich metadata for tracks and playlists
|
|
7
|
+
* **Download** a track stream (high/low quality)
|
|
8
|
+
* **Discover** related tracks by URL or ID
|
|
7
9
|
|
|
8
|
-
|
|
10
|
+
> ⚠️ Respect SoundCloud’s Terms of Service and creator rights. This library is for personal/educational use; don’t redistribute copyrighted content without permission.
|
|
9
11
|
|
|
10
|
-
|
|
11
|
-
npm install @zibot/scdl
|
|
12
|
-
```
|
|
12
|
+
---
|
|
13
13
|
|
|
14
|
-
|
|
14
|
+
## Installation
|
|
15
15
|
|
|
16
16
|
```bash
|
|
17
|
+
npm i @zibot/scdl
|
|
18
|
+
# or
|
|
17
19
|
yarn add @zibot/scdl
|
|
20
|
+
# or
|
|
21
|
+
pnpm add @zibot/scdl
|
|
18
22
|
```
|
|
19
23
|
|
|
20
|
-
|
|
24
|
+
**Runtime:** Node.js 18+ is recommended (built-in `fetch`/WHATWG streams). Works with ESM or CommonJS.
|
|
25
|
+
|
|
26
|
+
---
|
|
27
|
+
|
|
28
|
+
## Quick Start
|
|
29
|
+
|
|
30
|
+
### ESM
|
|
31
|
+
|
|
32
|
+
```ts
|
|
33
|
+
import SoundCloud from "@zibot/scdl";
|
|
34
|
+
|
|
35
|
+
const sc = new SoundCloud({ init: true }); // auto-initialize clientId
|
|
36
|
+
|
|
37
|
+
(async () => {
|
|
38
|
+
// Search tracks
|
|
39
|
+
const results = await sc.searchTracks({ query: "lofi hip hop", limit: 5, type: "tracks" });
|
|
40
|
+
console.log(results);
|
|
41
|
+
|
|
42
|
+
// Track details
|
|
43
|
+
const track = await sc.getTrackDetails("https://soundcloud.com/user/track-slug");
|
|
44
|
+
console.log(track.title, track.user.username);
|
|
45
|
+
|
|
46
|
+
// Download a track (Readable stream)
|
|
47
|
+
const stream = await sc.downloadTrack("https://soundcloud.com/user/track-slug", { quality: "high" });
|
|
48
|
+
stream.pipe(process.stdout); // or pipe to fs.createWriteStream("track.mp3")
|
|
49
|
+
|
|
50
|
+
// Related tracks
|
|
51
|
+
const related = await sc.getRelatedTracks(track.id, { limit: 10 });
|
|
52
|
+
console.log(related.map(t => t.title));
|
|
53
|
+
})();
|
|
54
|
+
```
|
|
21
55
|
|
|
22
|
-
###
|
|
56
|
+
### CommonJS
|
|
23
57
|
|
|
24
|
-
|
|
58
|
+
```js
|
|
59
|
+
const SoundCloud = require("@zibot/scdl");
|
|
60
|
+
const fs = require("node:fs");
|
|
25
61
|
|
|
26
|
-
|
|
27
|
-
const { SoundCloud } = require("@zibot/scdl");
|
|
62
|
+
const sc = new SoundCloud({ init: true });
|
|
28
63
|
|
|
29
64
|
(async () => {
|
|
30
|
-
|
|
31
|
-
|
|
65
|
+
const stream = await sc.downloadTrack("https://soundcloud.com/user/track-slug");
|
|
66
|
+
stream.pipe(fs.createWriteStream("track.mp3"));
|
|
32
67
|
})();
|
|
33
68
|
```
|
|
34
69
|
|
|
35
70
|
---
|
|
36
71
|
|
|
37
|
-
|
|
72
|
+
## Initialization
|
|
38
73
|
|
|
39
|
-
|
|
74
|
+
The client can retrieve a valid `clientId` automatically.
|
|
40
75
|
|
|
41
|
-
|
|
76
|
+
```ts
|
|
77
|
+
// Option A: auto-init (recommended)
|
|
78
|
+
const sc = new SoundCloud({ init: true });
|
|
42
79
|
|
|
43
|
-
|
|
44
|
-
const
|
|
45
|
-
|
|
80
|
+
// Option B: manual init
|
|
81
|
+
const sc = new SoundCloud();
|
|
82
|
+
await sc.init(); // retrieves clientId
|
|
46
83
|
```
|
|
47
84
|
|
|
48
|
-
|
|
85
|
+
> You usually only need to call `init()` once per process. If you get authentication errors later, re-calling `init()` may refresh the client ID.
|
|
86
|
+
|
|
87
|
+
---
|
|
88
|
+
|
|
89
|
+
## API
|
|
90
|
+
|
|
91
|
+
### `new SoundCloud(options?)`
|
|
49
92
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
93
|
+
Create a client.
|
|
94
|
+
|
|
95
|
+
* `options.init?: boolean` – if `true`, calls `init()` internally.
|
|
96
|
+
|
|
97
|
+
**Properties**
|
|
98
|
+
|
|
99
|
+
* `clientId: string | null` – resolved after `init()`
|
|
100
|
+
* `apiBaseUrl: string` – internal base URL used for API calls
|
|
101
|
+
|
|
102
|
+
---
|
|
103
|
+
|
|
104
|
+
### `init(): Promise<void>`
|
|
105
|
+
|
|
106
|
+
Initialize the client (retrieve `clientId`). Call this if you didn’t pass `{ init: true }`.
|
|
53
107
|
|
|
54
108
|
---
|
|
55
109
|
|
|
56
|
-
|
|
110
|
+
### `searchTracks(options: SearchOptions): Promise<(Track | Playlist | User)[]>`
|
|
111
|
+
|
|
112
|
+
Search SoundCloud.
|
|
113
|
+
|
|
114
|
+
**Parameters – `SearchOptions`**
|
|
115
|
+
|
|
116
|
+
* `query: string` – search text
|
|
117
|
+
* `limit?: number` – default depends on endpoint (commonly 10–20)
|
|
118
|
+
* `offset?: number` – for pagination
|
|
119
|
+
* `type?: "all" | "tracks" | "playlists" | "users"` – filter result kinds (default `"all"`)
|
|
57
120
|
|
|
58
|
-
|
|
121
|
+
**Usage**
|
|
59
122
|
|
|
60
|
-
```
|
|
61
|
-
|
|
62
|
-
|
|
123
|
+
```ts
|
|
124
|
+
// Top tracks
|
|
125
|
+
const tracks = await sc.searchTracks({ query: "ambient study", type: "tracks", limit: 10 });
|
|
126
|
+
|
|
127
|
+
// Mixed kinds (tracks/playlists/users)
|
|
128
|
+
const mixed = await sc.searchTracks({ query: "chill", type: "all", limit: 5, offset: 5 });
|
|
63
129
|
```
|
|
64
130
|
|
|
65
|
-
|
|
131
|
+
---
|
|
132
|
+
|
|
133
|
+
### `getTrackDetails(url: string): Promise<Track>`
|
|
134
|
+
|
|
135
|
+
Get rich metadata for a single track by its public URL.
|
|
136
|
+
|
|
137
|
+
```ts
|
|
138
|
+
const t = await sc.getTrackDetails("https://soundcloud.com/artist/track");
|
|
139
|
+
console.log({
|
|
140
|
+
id: t.id,
|
|
141
|
+
title: t.title,
|
|
142
|
+
by: t.user.username,
|
|
143
|
+
streamables: t.media.transcodings.length,
|
|
144
|
+
});
|
|
145
|
+
```
|
|
66
146
|
|
|
67
|
-
|
|
147
|
+
**`Track`**
|
|
148
|
+
|
|
149
|
+
```ts
|
|
150
|
+
interface Track {
|
|
151
|
+
id: number;
|
|
152
|
+
title: string;
|
|
153
|
+
url: string;
|
|
154
|
+
user: { id: number; username: string };
|
|
155
|
+
media: {
|
|
156
|
+
transcodings: {
|
|
157
|
+
url: string;
|
|
158
|
+
format: { protocol: string; mime_type: string };
|
|
159
|
+
}[];
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
```
|
|
68
163
|
|
|
69
164
|
---
|
|
70
165
|
|
|
71
|
-
|
|
166
|
+
### `getPlaylistDetails(url: string): Promise<Playlist>`
|
|
72
167
|
|
|
73
|
-
|
|
168
|
+
Fetch playlist metadata and contained tracks.
|
|
74
169
|
|
|
75
|
-
```
|
|
76
|
-
const
|
|
77
|
-
console.log(
|
|
170
|
+
```ts
|
|
171
|
+
const pl = await sc.getPlaylistDetails("https://soundcloud.com/artist/sets/playlist-slug");
|
|
172
|
+
console.log(pl.title, pl.tracks.length);
|
|
78
173
|
```
|
|
79
174
|
|
|
80
|
-
|
|
175
|
+
**`Playlist`**
|
|
81
176
|
|
|
82
|
-
|
|
177
|
+
```ts
|
|
178
|
+
interface Playlist {
|
|
179
|
+
id: number;
|
|
180
|
+
title: string;
|
|
181
|
+
tracks: Track[];
|
|
182
|
+
}
|
|
183
|
+
```
|
|
83
184
|
|
|
84
185
|
---
|
|
85
186
|
|
|
86
|
-
|
|
187
|
+
### `downloadTrack(url: string, options?: DownloadOptions): Promise<Readable>`
|
|
188
|
+
|
|
189
|
+
Download a track as a Node `Readable` stream.
|
|
190
|
+
|
|
191
|
+
**Parameters – `DownloadOptions`**
|
|
87
192
|
|
|
88
|
-
|
|
193
|
+
* `quality?: "high" | "low"` – choose available transcoding (default implementation prefers higher quality when available)
|
|
89
194
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
195
|
+
**Examples**
|
|
196
|
+
|
|
197
|
+
```ts
|
|
198
|
+
import fs from "node:fs";
|
|
199
|
+
|
|
200
|
+
const read = await sc.downloadTrack("https://soundcloud.com/user/track", { quality: "high" });
|
|
201
|
+
await new Promise((resolve, reject) => {
|
|
202
|
+
read.pipe(fs.createWriteStream("track.mp3"))
|
|
203
|
+
.on("finish", resolve)
|
|
204
|
+
.on("error", reject);
|
|
205
|
+
});
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
Pipe to any writable destination (stdout, HTTP response, cloud storage SDKs, etc.).
|
|
209
|
+
|
|
210
|
+
---
|
|
211
|
+
|
|
212
|
+
### `getRelatedTracks(track: string | number, opts?: { limit?: number; offset?: number }): Promise<Track[]>`
|
|
213
|
+
|
|
214
|
+
Fetch tracks related to a given track by **URL** or **numeric ID**.
|
|
215
|
+
|
|
216
|
+
```ts
|
|
217
|
+
// by URL
|
|
218
|
+
const relByUrl = await sc.getRelatedTracks("https://soundcloud.com/artist/track", { limit: 6 });
|
|
219
|
+
|
|
220
|
+
// by ID
|
|
221
|
+
const base = await sc.getTrackDetails("https://soundcloud.com/artist/track");
|
|
222
|
+
const relById = await sc.getRelatedTracks(base.id, { limit: 6 });
|
|
93
223
|
```
|
|
94
224
|
|
|
95
|
-
|
|
225
|
+
---
|
|
226
|
+
|
|
227
|
+
## Types
|
|
228
|
+
|
|
229
|
+
```ts
|
|
230
|
+
export interface SearchOptions {
|
|
231
|
+
query: string;
|
|
232
|
+
limit?: number;
|
|
233
|
+
offset?: number;
|
|
234
|
+
type?: "all" | "tracks" | "playlists" | "users";
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
export interface DownloadOptions {
|
|
238
|
+
quality?: "high" | "low";
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
export interface User {
|
|
242
|
+
id: number;
|
|
243
|
+
username: string;
|
|
244
|
+
followers_count: number;
|
|
245
|
+
track_count: number;
|
|
246
|
+
}
|
|
247
|
+
```
|
|
96
248
|
|
|
97
|
-
|
|
98
|
-
- `options` (tùy chọn): Cấu hình tải xuống.
|
|
249
|
+
These types are exported for TypeScript consumers.
|
|
99
250
|
|
|
100
251
|
---
|
|
101
252
|
|
|
102
|
-
|
|
253
|
+
## Examples
|
|
254
|
+
|
|
255
|
+
### Save the highest-quality stream to disk
|
|
256
|
+
|
|
257
|
+
```ts
|
|
258
|
+
import fs from "node:fs";
|
|
259
|
+
import SoundCloud from "@zibot/scdl";
|
|
260
|
+
|
|
261
|
+
const sc = new SoundCloud({ init: true });
|
|
262
|
+
|
|
263
|
+
async function save(url: string, file: string) {
|
|
264
|
+
const stream = await sc.downloadTrack(url, { quality: "high" });
|
|
265
|
+
await new Promise<void>((resolve, reject) => {
|
|
266
|
+
stream.pipe(fs.createWriteStream(file))
|
|
267
|
+
.on("finish", resolve)
|
|
268
|
+
.on("error", reject);
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
save("https://soundcloud.com/user/track", "output.mp3");
|
|
273
|
+
```
|
|
274
|
+
|
|
275
|
+
### Basic search → pick first result → download
|
|
276
|
+
|
|
277
|
+
```ts
|
|
278
|
+
const [first] = await sc.searchTracks({ query: "deep house 2024", type: "tracks", limit: 1 });
|
|
279
|
+
if (first && "url" in first) {
|
|
280
|
+
const s = await sc.downloadTrack(first.url);
|
|
281
|
+
s.pipe(process.stdout);
|
|
282
|
+
}
|
|
283
|
+
```
|
|
284
|
+
|
|
285
|
+
### Paginate results
|
|
286
|
+
|
|
287
|
+
```ts
|
|
288
|
+
const page1 = await sc.searchTracks({ query: "vaporwave", type: "tracks", limit: 20, offset: 0 });
|
|
289
|
+
const page2 = await sc.searchTracks({ query: "vaporwave", type: "tracks", limit: 20, offset: 20 });
|
|
290
|
+
```
|
|
291
|
+
|
|
292
|
+
---
|
|
293
|
+
|
|
294
|
+
## Error Handling & Tips
|
|
295
|
+
|
|
296
|
+
* **Initialization:** If a method throws due to missing/expired `clientId`, call `await sc.init()` and retry.
|
|
297
|
+
* **Quality selection:** Not all tracks expose multiple transcodings; the library will fall back when needed.
|
|
298
|
+
* **Rate limits / 429:** Back off and retry with an exponential strategy.
|
|
299
|
+
* **Private/geo-restricted tracks:** Details/downloads may be unavailable.
|
|
300
|
+
* **Networking:** Wrap downloads with proper error and close handlers to avoid dangling file descriptors.
|
|
301
|
+
|
|
302
|
+
---
|
|
303
|
+
|
|
304
|
+
## FAQ
|
|
305
|
+
|
|
306
|
+
**Q: Can I use this in the browser?**
|
|
307
|
+
This package targets Node.js (it returns Node `Readable`). Browser use is not supported.
|
|
308
|
+
|
|
309
|
+
**Q: What audio format do I get?**
|
|
310
|
+
Whatever the selected transcoding provides (commonly progressive MP3 or HLS AAC). You may need to remux/encode if you require a specific container/codec.
|
|
311
|
+
|
|
312
|
+
**Q: Do I need my own client ID?**
|
|
313
|
+
The client auto-discovers a valid `clientId`. If discovery fails due to upstream changes, update the package to the latest version.
|
|
314
|
+
|
|
315
|
+
---
|
|
316
|
+
|
|
317
|
+
## Contributing
|
|
318
|
+
|
|
319
|
+
PRs and issues are welcome! Please include:
|
|
103
320
|
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
321
|
+
* A clear description of the change
|
|
322
|
+
* Repro steps (for bugs)
|
|
323
|
+
* Tests where possible
|
|
107
324
|
|
|
108
325
|
---
|
|
109
326
|
|
|
110
|
-
##
|
|
327
|
+
## License
|
|
111
328
|
|
|
112
|
-
|
|
329
|
+
MIT © Zibot
|
|
113
330
|
|
|
114
331
|
---
|
|
115
332
|
|
|
116
|
-
##
|
|
333
|
+
## Disclaimer
|
|
117
334
|
|
|
118
|
-
|
|
335
|
+
This project is not affiliated with SoundCloud. Use responsibly and comply with all applicable laws and SoundCloud’s Terms of Service.
|
package/index.d.ts
CHANGED
|
@@ -1,18 +1,18 @@
|
|
|
1
1
|
import { Readable } from "stream";
|
|
2
2
|
|
|
3
3
|
// Types
|
|
4
|
-
interface SearchOptions {
|
|
4
|
+
export interface SearchOptions {
|
|
5
5
|
query: string;
|
|
6
6
|
limit?: number;
|
|
7
7
|
offset?: number;
|
|
8
8
|
type?: "all" | "tracks" | "playlists" | "users";
|
|
9
9
|
}
|
|
10
10
|
|
|
11
|
-
interface DownloadOptions {
|
|
11
|
+
export interface DownloadOptions {
|
|
12
12
|
quality?: "high" | "low";
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
-
interface Track {
|
|
15
|
+
export interface Track {
|
|
16
16
|
id: number;
|
|
17
17
|
title: string;
|
|
18
18
|
url: string;
|
|
@@ -25,13 +25,13 @@ interface Track {
|
|
|
25
25
|
};
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
-
interface Playlist {
|
|
28
|
+
export interface Playlist {
|
|
29
29
|
id: number;
|
|
30
30
|
title: string;
|
|
31
31
|
tracks: Track[];
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
-
interface User {
|
|
34
|
+
export interface User {
|
|
35
35
|
id: number;
|
|
36
36
|
username: string;
|
|
37
37
|
followers_count: number;
|
|
@@ -68,6 +68,11 @@ declare class SoundCloud {
|
|
|
68
68
|
* Download a track as a stream.
|
|
69
69
|
*/
|
|
70
70
|
downloadTrack(url: string, options?: DownloadOptions): Promise<Readable>;
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Get related tracks for a given track (by URL or ID).
|
|
74
|
+
*/
|
|
75
|
+
getRelatedTracks(track: string | number, opts?: { limit?: number; offset?: number }): Promise<Track[]>;
|
|
71
76
|
}
|
|
72
77
|
|
|
73
78
|
export = SoundCloud;
|
package/index.js
CHANGED
|
@@ -1,153 +1,290 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
1
3
|
const axios = require("axios");
|
|
2
4
|
const m3u8stream = require("m3u8stream");
|
|
3
5
|
|
|
4
6
|
class SoundCloud {
|
|
7
|
+
/**
|
|
8
|
+
* @param {Object} options
|
|
9
|
+
* @param {boolean} [options.autoInit=true] - tự động lấy clientId
|
|
10
|
+
* @param {string} [options.apiBaseUrl="https://api-v2.soundcloud.com"]
|
|
11
|
+
* @param {number} [options.timeout=12_000]
|
|
12
|
+
* @param {(id:string)=>void} [options.onClientId] - callback khi lấy được clientId (để cache ngoài)
|
|
13
|
+
* @param {string} [options.clientId] - nếu bạn đã có sẵn clientId hợp lệ
|
|
14
|
+
*/
|
|
5
15
|
constructor(options = {}) {
|
|
6
|
-
const defaultOptions = {
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
16
|
+
const defaultOptions = {
|
|
17
|
+
autoInit: true,
|
|
18
|
+
apiBaseUrl: "https://api-v2.soundcloud.com",
|
|
19
|
+
timeout: 12_000,
|
|
20
|
+
onClientId: null,
|
|
21
|
+
clientId: null,
|
|
22
|
+
};
|
|
23
|
+
this.opts = { ...defaultOptions, ...options };
|
|
24
|
+
this.apiBaseUrl = this.opts.apiBaseUrl;
|
|
25
|
+
this.clientId = this.opts.clientId || null;
|
|
26
|
+
|
|
27
|
+
this.http = axios.create({
|
|
28
|
+
timeout: this.opts.timeout,
|
|
29
|
+
headers: {
|
|
30
|
+
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124 Safari/537.36",
|
|
31
|
+
Accept: "application/json, text/javascript, */*; q=0.01",
|
|
32
|
+
Referer: "https://soundcloud.com/",
|
|
33
|
+
},
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
this._initPromise = null;
|
|
37
|
+
if (this.opts.autoInit && !this.clientId) {
|
|
38
|
+
this._initPromise = this.init();
|
|
39
|
+
}
|
|
11
40
|
}
|
|
12
41
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
for (
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
42
|
+
async ensureReady() {
|
|
43
|
+
if (this.clientId) return;
|
|
44
|
+
if (!this._initPromise) this._initPromise = this.init();
|
|
45
|
+
await this._initPromise;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async _getJson(url, { retries = 3, retryOn = [429, 500, 502, 503, 504] } = {}) {
|
|
49
|
+
let lastErr;
|
|
50
|
+
for (let attempt = 0; attempt <= retries; attempt++) {
|
|
51
|
+
try {
|
|
52
|
+
const { data } = await this.http.get(url);
|
|
53
|
+
return data;
|
|
54
|
+
} catch (err) {
|
|
55
|
+
lastErr = err;
|
|
56
|
+
const status = err?.response?.status;
|
|
57
|
+
const shouldRetry = retryOn.includes(status) || err.code === "ECONNABORTED";
|
|
58
|
+
if (!shouldRetry || attempt === retries) break;
|
|
59
|
+
const delay = 300 * 2 ** attempt + Math.floor(Math.random() * 150);
|
|
60
|
+
await new Promise((r) => setTimeout(r, delay));
|
|
27
61
|
}
|
|
28
62
|
}
|
|
63
|
+
throw lastErr;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async init() {
|
|
67
|
+
if (this.clientId) return this.clientId;
|
|
68
|
+
|
|
69
|
+
const regexes = [
|
|
70
|
+
/client_id=([a-zA-Z0-9]{32})/g, // client_id=XXXXXXXX...
|
|
71
|
+
/client_id:"([a-zA-Z0-9]{32})"/, // "client_id":"XXXXXXXX..."
|
|
72
|
+
/"client_id"\s*:\s*"([a-zA-Z0-9]{32})"/g, // "client_id":"XXXXXXXX..."
|
|
73
|
+
];
|
|
74
|
+
|
|
75
|
+
const homeHtml = await this._getJson("https://soundcloud.com").catch(() => null);
|
|
76
|
+
const scriptUrls =
|
|
77
|
+
(typeof homeHtml === "string"
|
|
78
|
+
? (homeHtml.match(/<script[^>]+src="([^"]+)"/g) || []).map((t) => t.match(/src="([^"]+)"/)?.[1]).filter(Boolean)
|
|
79
|
+
: []) || [];
|
|
80
|
+
|
|
81
|
+
const candidates = [
|
|
82
|
+
...scriptUrls.filter((u) => /sndcdn\.com|soundcloud\.com/.test(u)),
|
|
83
|
+
"https://a-v2.sndcdn.com/assets/1-ff6b3.js",
|
|
84
|
+
"https://a-v2.sndcdn.com/assets/2-ff6b3.js",
|
|
85
|
+
];
|
|
86
|
+
|
|
87
|
+
for (const url of candidates) {
|
|
88
|
+
try {
|
|
89
|
+
const res = await this.http.get(url, { responseType: "text" });
|
|
90
|
+
const text = res.data || "";
|
|
91
|
+
for (const re of regexes) {
|
|
92
|
+
const m = re.exec(text);
|
|
93
|
+
if (m && m[1]) {
|
|
94
|
+
this.clientId = m[1];
|
|
95
|
+
if (typeof this.opts.onClientId === "function") this.opts.onClientId(this.clientId);
|
|
96
|
+
return this.clientId;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
} catch {}
|
|
100
|
+
}
|
|
29
101
|
|
|
30
|
-
throw new Error("
|
|
102
|
+
throw new Error("Không thể lấy client_id từ SoundCloud");
|
|
31
103
|
}
|
|
32
104
|
|
|
33
|
-
// Search SoundCloud
|
|
34
105
|
async searchTracks({ query, limit = 30, offset = 0, type = "all" }) {
|
|
106
|
+
await this.ensureReady();
|
|
35
107
|
const path = type === "all" ? "" : `/${type}`;
|
|
36
|
-
const url =
|
|
37
|
-
|
|
38
|
-
|
|
108
|
+
const url =
|
|
109
|
+
`${this.apiBaseUrl}/search${path}` +
|
|
110
|
+
`?q=${encodeURIComponent(query)}` +
|
|
111
|
+
`&limit=${limit}&offset=${offset}` +
|
|
112
|
+
`&access=playable&client_id=${this.clientId}`;
|
|
39
113
|
try {
|
|
40
|
-
const
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
return data.collection.filter((track) => {
|
|
47
|
-
if (!track.permalink_url || !track.title || !track.duration) return false;
|
|
48
|
-
return true;
|
|
49
|
-
});
|
|
50
|
-
} catch (error) {
|
|
51
|
-
console.error("Search error:", error.message || error);
|
|
114
|
+
const data = await this._getJson(url);
|
|
115
|
+
const collection = Array.isArray(data?.collection) ? data.collection : [];
|
|
116
|
+
return collection.filter((t) => t?.permalink_url && t?.title && t?.duration);
|
|
117
|
+
} catch (e) {
|
|
52
118
|
throw new Error("Search failed");
|
|
53
119
|
}
|
|
54
120
|
}
|
|
55
121
|
|
|
56
|
-
// Get track details
|
|
57
122
|
async getTrackDetails(trackUrl) {
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
}
|
|
123
|
+
await this.ensureReady();
|
|
124
|
+
const item = await this.fetchItem(trackUrl);
|
|
125
|
+
if (item?.kind !== "track") throw new Error("Invalid track URL");
|
|
126
|
+
return item;
|
|
63
127
|
}
|
|
64
128
|
|
|
65
|
-
// Get playlist details
|
|
66
129
|
async getPlaylistDetails(playlistUrl) {
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
130
|
+
await this.ensureReady();
|
|
131
|
+
const playlist = await this.fetchItem(playlistUrl);
|
|
132
|
+
if (playlist?.kind !== "playlist") throw new Error("Invalid playlist URL");
|
|
70
133
|
|
|
71
|
-
|
|
72
|
-
|
|
134
|
+
const tracks = Array.isArray(playlist.tracks) ? playlist.tracks : [];
|
|
135
|
+
const loaded = tracks.filter((t) => t?.title);
|
|
136
|
+
const unloadedIds = tracks.filter((t) => !t?.title && t?.id).map((t) => t.id);
|
|
73
137
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
return playlist;
|
|
80
|
-
} catch (error) {
|
|
81
|
-
throw new Error("Invalid playlist URL");
|
|
138
|
+
if (unloadedIds.length) {
|
|
139
|
+
const more = await this.fetchTracksByIds(unloadedIds);
|
|
140
|
+
playlist.tracks = loaded.concat(more);
|
|
141
|
+
} else {
|
|
142
|
+
playlist.tracks = loaded;
|
|
82
143
|
}
|
|
144
|
+
return playlist;
|
|
83
145
|
}
|
|
84
146
|
|
|
85
|
-
// Download track stream
|
|
86
147
|
async downloadTrack(trackUrl, options = {}) {
|
|
148
|
+
await this.ensureReady();
|
|
87
149
|
try {
|
|
88
150
|
const track = await this.getTrackDetails(trackUrl);
|
|
89
|
-
const transcoding = track?.media?.transcodings?.find((t) => t.format.protocol === "hls");
|
|
90
151
|
|
|
91
|
-
if (
|
|
152
|
+
if (track?.policy === "BLOCK" || track?.state === "blocked") {
|
|
153
|
+
throw new Error("Track bị chặn (policy/geo).");
|
|
154
|
+
}
|
|
155
|
+
if (track?.has_downloads === false && track?.streamable === false) {
|
|
156
|
+
throw new Error("Track không cho phép stream.");
|
|
157
|
+
}
|
|
92
158
|
|
|
93
|
-
const
|
|
94
|
-
|
|
159
|
+
const transcoding = this._pickBestTranscoding(track);
|
|
160
|
+
if (!transcoding) throw new Error("Không tìm thấy stream phù hợp.");
|
|
161
|
+
|
|
162
|
+
const streamUrl = await this.getStreamUrl(transcoding.url);
|
|
163
|
+
if (transcoding.format?.protocol === "hls") {
|
|
164
|
+
return m3u8stream(streamUrl, {
|
|
165
|
+
requestOptions: {
|
|
166
|
+
headers: { "User-Agent": this.http.defaults.headers["User-Agent"] },
|
|
167
|
+
},
|
|
168
|
+
...options,
|
|
169
|
+
});
|
|
170
|
+
} else {
|
|
171
|
+
const res = await this.http.get(streamUrl, { responseType: "stream" });
|
|
172
|
+
return res.data;
|
|
173
|
+
}
|
|
95
174
|
} catch (e) {
|
|
96
|
-
console.error("Failed to download track");
|
|
175
|
+
console.error("Failed to download track:", e?.message || e);
|
|
97
176
|
return null;
|
|
98
177
|
}
|
|
99
178
|
}
|
|
100
179
|
|
|
101
|
-
// Fetch single item (track/playlist/user)
|
|
102
180
|
async fetchItem(itemUrl) {
|
|
103
|
-
|
|
181
|
+
await this.ensureReady();
|
|
182
|
+
const url = `${this.apiBaseUrl}/resolve?url=${encodeURIComponent(itemUrl)}&client_id=${this.clientId}`;
|
|
104
183
|
try {
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
} catch (error) {
|
|
184
|
+
return await this._getJson(url);
|
|
185
|
+
} catch (e) {
|
|
108
186
|
throw new Error("Failed to fetch item details");
|
|
109
187
|
}
|
|
110
188
|
}
|
|
111
189
|
|
|
112
|
-
// Fetch multiple tracks by their IDs
|
|
113
190
|
async fetchTracksByIds(trackIds) {
|
|
114
|
-
|
|
191
|
+
await this.ensureReady();
|
|
192
|
+
const ids = Array.from(new Set(trackIds.filter(Boolean)));
|
|
193
|
+
if (!ids.length) return [];
|
|
194
|
+
const chunkSize = 50;
|
|
115
195
|
const chunks = [];
|
|
116
|
-
for (let i = 0; i <
|
|
117
|
-
chunks.push(trackIds.slice(i, i + chunkSize));
|
|
118
|
-
}
|
|
196
|
+
for (let i = 0; i < ids.length; i += chunkSize) chunks.push(ids.slice(i, i + chunkSize));
|
|
119
197
|
|
|
120
198
|
try {
|
|
121
199
|
const results = await Promise.all(
|
|
122
200
|
chunks.map(async (chunk) => {
|
|
123
|
-
const
|
|
124
|
-
|
|
125
|
-
const { data } = await axios.get(url);
|
|
126
|
-
return data;
|
|
201
|
+
const url = `${this.apiBaseUrl}/tracks?ids=${chunk.join(",")}&client_id=${this.clientId}`;
|
|
202
|
+
return await this._getJson(url);
|
|
127
203
|
}),
|
|
128
204
|
);
|
|
129
|
-
|
|
130
|
-
// Combine results from all chunks
|
|
131
205
|
return results.flat();
|
|
132
206
|
} catch (error) {
|
|
133
207
|
console.error("Failed to fetch tracks by IDs:", {
|
|
134
208
|
clientId: this.clientId,
|
|
135
|
-
|
|
209
|
+
status: error?.response?.status,
|
|
210
|
+
error: error?.response?.data || error?.message,
|
|
136
211
|
});
|
|
137
212
|
throw new Error("Failed to fetch tracks by IDs");
|
|
138
213
|
}
|
|
139
214
|
}
|
|
140
215
|
|
|
141
|
-
// Get HLS stream URL
|
|
142
216
|
async getStreamUrl(transcodingUrl) {
|
|
143
|
-
|
|
217
|
+
await this.ensureReady();
|
|
218
|
+
const url = `${transcodingUrl}${transcodingUrl.includes("?") ? "&" : "?"}client_id=${this.clientId}`;
|
|
144
219
|
try {
|
|
145
|
-
const
|
|
220
|
+
const data = await this._getJson(url);
|
|
221
|
+
if (!data?.url) throw new Error("No stream URL in response");
|
|
146
222
|
return data.url;
|
|
147
223
|
} catch (error) {
|
|
224
|
+
if (error?.response?.status === 401 || error?.response?.status === 403) {
|
|
225
|
+
this.clientId = null;
|
|
226
|
+
this._initPromise = this.init();
|
|
227
|
+
await this._initPromise;
|
|
228
|
+
const retryUrl = `${transcodingUrl}${transcodingUrl.includes("?") ? "&" : "?"}client_id=${this.clientId}`;
|
|
229
|
+
const data = await this._getJson(retryUrl);
|
|
230
|
+
if (!data?.url) throw new Error("No stream URL in response (after refresh)");
|
|
231
|
+
return data.url;
|
|
232
|
+
}
|
|
148
233
|
throw new Error("Failed to fetch stream URL");
|
|
149
234
|
}
|
|
150
235
|
}
|
|
236
|
+
|
|
237
|
+
/** pick transcoding: HLS (opus > mp3), fallback progressive mp3 */
|
|
238
|
+
_pickBestTranscoding(track) {
|
|
239
|
+
const list = Array.isArray(track?.media?.transcodings) ? track.media.transcodings : [];
|
|
240
|
+
if (!list.length) return null;
|
|
241
|
+
|
|
242
|
+
const score = (t) => {
|
|
243
|
+
const proto = t?.format?.protocol;
|
|
244
|
+
const mime = t?.format?.mime_type || "";
|
|
245
|
+
if (proto === "hls" && mime.includes("opus")) return 100;
|
|
246
|
+
if (proto === "hls" && mime.includes("mpeg")) return 90;
|
|
247
|
+
if (proto === "progressive" && mime.includes("mpeg")) return 70;
|
|
248
|
+
return 10;
|
|
249
|
+
};
|
|
250
|
+
|
|
251
|
+
return [...list].sort((a, b) => score(b) - score(a))[0];
|
|
252
|
+
}
|
|
253
|
+
async _resolveTrackId(input) {
|
|
254
|
+
await this.ensureReady();
|
|
255
|
+
if (!input) throw new Error("Missing track identifier");
|
|
256
|
+
if (typeof input === "number" || /^[0-9]+$/.test(String(input))) {
|
|
257
|
+
return Number(input);
|
|
258
|
+
}
|
|
259
|
+
const item = await this.fetchItem(input);
|
|
260
|
+
if (item?.kind !== "track" || !item?.id) {
|
|
261
|
+
throw new Error("Cannot resolve track ID from input");
|
|
262
|
+
}
|
|
263
|
+
return item.id;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Lấy danh sách related tracks cho một track (URL hoặc ID)
|
|
268
|
+
* @param {string|number} track - track URL hoặc track ID
|
|
269
|
+
* @param {object} opts
|
|
270
|
+
* @param {number} [opts.limit=20]
|
|
271
|
+
* @param {number} [opts.offset=0]
|
|
272
|
+
* @returns {Promise<Array>} danh sách track tương tự
|
|
273
|
+
*/
|
|
274
|
+
async getRelatedTracks(track, { limit = 20, offset = 0 } = {}) {
|
|
275
|
+
await this.ensureReady();
|
|
276
|
+
const id = await this._resolveTrackId(track);
|
|
277
|
+
|
|
278
|
+
const url = `${this.apiBaseUrl}/tracks/${id}/related` + `?limit=${limit}&offset=${offset}&client_id=${this.clientId}`;
|
|
279
|
+
|
|
280
|
+
try {
|
|
281
|
+
const data = await this._getJson(url);
|
|
282
|
+
const collection = Array.isArray(data?.collection) ? data.collection : Array.isArray(data) ? data : [];
|
|
283
|
+
return collection.filter((t) => t?.permalink_url && t?.title && t?.duration);
|
|
284
|
+
} catch (e) {
|
|
285
|
+
return [];
|
|
286
|
+
}
|
|
287
|
+
}
|
|
151
288
|
}
|
|
152
289
|
|
|
153
290
|
module.exports = SoundCloud;
|
package/package.json
CHANGED
|
@@ -1,35 +1,32 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "@zibot/scdl",
|
|
3
|
-
"version": "0.0.
|
|
4
|
-
"description": "Soucloud download",
|
|
5
|
-
"main": "index.js",
|
|
6
|
-
"types": "index.d.ts",
|
|
7
|
-
"scripts": {
|
|
8
|
-
"test": "echo \"Error: no test specified\" && exit 1"
|
|
9
|
-
},
|
|
10
|
-
"repository": {
|
|
11
|
-
"type": "git",
|
|
12
|
-
"url": "git+https://github.com/
|
|
13
|
-
},
|
|
14
|
-
"keywords": [
|
|
15
|
-
"@zibot/scdl",
|
|
16
|
-
"scdl"
|
|
17
|
-
],
|
|
18
|
-
"author": "Ziji",
|
|
19
|
-
"license": "ISC",
|
|
20
|
-
"bugs": {
|
|
21
|
-
"url": "https://github.com/
|
|
22
|
-
},
|
|
23
|
-
"publishConfig": {
|
|
24
|
-
"access": "public"
|
|
25
|
-
},
|
|
26
|
-
"homepage": "https://github.com/
|
|
27
|
-
"
|
|
28
|
-
"
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
"m3u8stream": "^0.8.6"
|
|
34
|
-
}
|
|
35
|
-
}
|
|
1
|
+
{
|
|
2
|
+
"name": "@zibot/scdl",
|
|
3
|
+
"version": "0.0.6",
|
|
4
|
+
"description": "Soucloud download",
|
|
5
|
+
"main": "index.js",
|
|
6
|
+
"types": "index.d.ts",
|
|
7
|
+
"scripts": {
|
|
8
|
+
"test": "echo \"Error: no test specified\" && exit 1"
|
|
9
|
+
},
|
|
10
|
+
"repository": {
|
|
11
|
+
"type": "git",
|
|
12
|
+
"url": "git+https://github.com/ZiProject/scdl.git"
|
|
13
|
+
},
|
|
14
|
+
"keywords": [
|
|
15
|
+
"@zibot/scdl",
|
|
16
|
+
"scdl"
|
|
17
|
+
],
|
|
18
|
+
"author": "Ziji",
|
|
19
|
+
"license": "ISC",
|
|
20
|
+
"bugs": {
|
|
21
|
+
"url": "https://github.com/ZiProject/scdl/issues"
|
|
22
|
+
},
|
|
23
|
+
"publishConfig": {
|
|
24
|
+
"access": "public"
|
|
25
|
+
},
|
|
26
|
+
"homepage": "https://github.com/ZiProject/scdl#readme",
|
|
27
|
+
"dependencies": {
|
|
28
|
+
"axios": "^1.13.2",
|
|
29
|
+
"events": "^3.3.0",
|
|
30
|
+
"m3u8stream": "^0.8.6"
|
|
31
|
+
}
|
|
32
|
+
}
|