@zibot/scdl 0.0.6 → 0.1.1
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 +73 -102
- package/index.d.ts +148 -12
- package/index.js +114 -64
- package/package.json +2 -2
- package/test.js +43 -0
package/README.md
CHANGED
|
@@ -1,13 +1,15 @@
|
|
|
1
1
|
# @zibot/scdl
|
|
2
2
|
|
|
3
|
-
A tiny, promise-based Node.js client for searching SoundCloud, fetching track/playlist details, and downloading tracks as readable
|
|
3
|
+
A tiny, promise-based Node.js client for searching SoundCloud, fetching track/playlist details, and downloading tracks as readable
|
|
4
|
+
streams.
|
|
4
5
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
6
|
+
- **Search** tracks, playlists, and users
|
|
7
|
+
- **Inspect** rich metadata for tracks and playlists
|
|
8
|
+
- **Download** a track stream (high/low quality)
|
|
9
|
+
- **Discover** related tracks by URL or ID
|
|
9
10
|
|
|
10
|
-
> ⚠️ Respect SoundCloud’s Terms of Service and creator rights. This library is for personal/educational use; don’t redistribute
|
|
11
|
+
> ⚠️ Respect SoundCloud’s Terms of Service and creator rights. This library is for personal/educational use; don’t redistribute
|
|
12
|
+
> copyrighted content without permission.
|
|
11
13
|
|
|
12
14
|
---
|
|
13
15
|
|
|
@@ -35,21 +37,21 @@ import SoundCloud from "@zibot/scdl";
|
|
|
35
37
|
const sc = new SoundCloud({ init: true }); // auto-initialize clientId
|
|
36
38
|
|
|
37
39
|
(async () => {
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
40
|
+
// Search tracks
|
|
41
|
+
const results = await sc.search({ query: "lofi hip hop", limit: 5, type: "tracks" });
|
|
42
|
+
console.log(results);
|
|
41
43
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
44
|
+
// Track details
|
|
45
|
+
const track = await sc.getTrackDetails("https://soundcloud.com/user/track-slug");
|
|
46
|
+
console.log(track.title, track.user.username);
|
|
45
47
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
48
|
+
// Download a track (Readable stream)
|
|
49
|
+
const stream = await sc.downloadTrack("https://soundcloud.com/user/track-slug", { quality: "high" });
|
|
50
|
+
stream.pipe(process.stdout); // or pipe to fs.createWriteStream("track.mp3")
|
|
49
51
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
52
|
+
// Related tracks
|
|
53
|
+
const related = await sc.getRelatedTracks(track.id, { limit: 10 });
|
|
54
|
+
console.log(related.map((t) => t.title));
|
|
53
55
|
})();
|
|
54
56
|
```
|
|
55
57
|
|
|
@@ -62,8 +64,8 @@ const fs = require("node:fs");
|
|
|
62
64
|
const sc = new SoundCloud({ init: true });
|
|
63
65
|
|
|
64
66
|
(async () => {
|
|
65
|
-
|
|
66
|
-
|
|
67
|
+
const stream = await sc.downloadTrack("https://soundcloud.com/user/track-slug");
|
|
68
|
+
stream.pipe(fs.createWriteStream("track.mp3"));
|
|
67
69
|
})();
|
|
68
70
|
```
|
|
69
71
|
|
|
@@ -82,7 +84,8 @@ const sc = new SoundCloud();
|
|
|
82
84
|
await sc.init(); // retrieves clientId
|
|
83
85
|
```
|
|
84
86
|
|
|
85
|
-
> You usually only need to call `init()` once per process. If you get authentication errors later, re-calling `init()` may refresh
|
|
87
|
+
> You usually only need to call `init()` once per process. If you get authentication errors later, re-calling `init()` may refresh
|
|
88
|
+
> the client ID.
|
|
86
89
|
|
|
87
90
|
---
|
|
88
91
|
|
|
@@ -92,12 +95,12 @@ await sc.init(); // retrieves clientId
|
|
|
92
95
|
|
|
93
96
|
Create a client.
|
|
94
97
|
|
|
95
|
-
|
|
98
|
+
- `options.init?: boolean` – if `true`, calls `init()` internally.
|
|
96
99
|
|
|
97
100
|
**Properties**
|
|
98
101
|
|
|
99
|
-
|
|
100
|
-
|
|
102
|
+
- `clientId: string | null` – resolved after `init()`
|
|
103
|
+
- `apiBaseUrl: string` – internal base URL used for API calls
|
|
101
104
|
|
|
102
105
|
---
|
|
103
106
|
|
|
@@ -107,25 +110,25 @@ Initialize the client (retrieve `clientId`). Call this if you didn’t pass `{ i
|
|
|
107
110
|
|
|
108
111
|
---
|
|
109
112
|
|
|
110
|
-
### `
|
|
113
|
+
### `search(options: SearchOptions): Promise<(Track | Playlist | User)[]>`
|
|
111
114
|
|
|
112
115
|
Search SoundCloud.
|
|
113
116
|
|
|
114
117
|
**Parameters – `SearchOptions`**
|
|
115
118
|
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
119
|
+
- `query: string` – search text
|
|
120
|
+
- `limit?: number` – default depends on endpoint (commonly 10–20)
|
|
121
|
+
- `offset?: number` – for pagination
|
|
122
|
+
- `type?: "all" | "tracks" | "playlists" | "users"` – filter result kinds (default `"all"`)
|
|
120
123
|
|
|
121
124
|
**Usage**
|
|
122
125
|
|
|
123
126
|
```ts
|
|
124
127
|
// Top tracks
|
|
125
|
-
const tracks = await sc.
|
|
128
|
+
const tracks = await sc.search({ query: "ambient study", type: "tracks", limit: 10 });
|
|
126
129
|
|
|
127
130
|
// Mixed kinds (tracks/playlists/users)
|
|
128
|
-
const mixed = await sc.
|
|
131
|
+
const mixed = await sc.search({ query: "chill", type: "all", limit: 5, offset: 5 });
|
|
129
132
|
```
|
|
130
133
|
|
|
131
134
|
---
|
|
@@ -137,30 +140,12 @@ Get rich metadata for a single track by its public URL.
|
|
|
137
140
|
```ts
|
|
138
141
|
const t = await sc.getTrackDetails("https://soundcloud.com/artist/track");
|
|
139
142
|
console.log({
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
streamables: t.media.transcodings.length,
|
|
143
|
+
id: t.id,
|
|
144
|
+
title: t.title,
|
|
145
|
+
url: t.permalink_url,
|
|
144
146
|
});
|
|
145
147
|
```
|
|
146
148
|
|
|
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
|
-
```
|
|
163
|
-
|
|
164
149
|
---
|
|
165
150
|
|
|
166
151
|
### `getPlaylistDetails(url: string): Promise<Playlist>`
|
|
@@ -172,16 +157,6 @@ const pl = await sc.getPlaylistDetails("https://soundcloud.com/artist/sets/playl
|
|
|
172
157
|
console.log(pl.title, pl.tracks.length);
|
|
173
158
|
```
|
|
174
159
|
|
|
175
|
-
**`Playlist`**
|
|
176
|
-
|
|
177
|
-
```ts
|
|
178
|
-
interface Playlist {
|
|
179
|
-
id: number;
|
|
180
|
-
title: string;
|
|
181
|
-
tracks: Track[];
|
|
182
|
-
}
|
|
183
|
-
```
|
|
184
|
-
|
|
185
160
|
---
|
|
186
161
|
|
|
187
162
|
### `downloadTrack(url: string, options?: DownloadOptions): Promise<Readable>`
|
|
@@ -190,7 +165,7 @@ Download a track as a Node `Readable` stream.
|
|
|
190
165
|
|
|
191
166
|
**Parameters – `DownloadOptions`**
|
|
192
167
|
|
|
193
|
-
|
|
168
|
+
- `quality?: "high" | "low"` – choose available transcoding (default implementation prefers higher quality when available)
|
|
194
169
|
|
|
195
170
|
**Examples**
|
|
196
171
|
|
|
@@ -199,9 +174,7 @@ import fs from "node:fs";
|
|
|
199
174
|
|
|
200
175
|
const read = await sc.downloadTrack("https://soundcloud.com/user/track", { quality: "high" });
|
|
201
176
|
await new Promise((resolve, reject) => {
|
|
202
|
-
|
|
203
|
-
.on("finish", resolve)
|
|
204
|
-
.on("error", reject);
|
|
177
|
+
read.pipe(fs.createWriteStream("track.ts")).on("finish", resolve).on("error", reject);
|
|
205
178
|
});
|
|
206
179
|
```
|
|
207
180
|
|
|
@@ -228,21 +201,21 @@ const relById = await sc.getRelatedTracks(base.id, { limit: 6 });
|
|
|
228
201
|
|
|
229
202
|
```ts
|
|
230
203
|
export interface SearchOptions {
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
204
|
+
query: string;
|
|
205
|
+
limit?: number;
|
|
206
|
+
offset?: number;
|
|
207
|
+
type?: "all" | "tracks" | "playlists" | "users";
|
|
235
208
|
}
|
|
236
209
|
|
|
237
210
|
export interface DownloadOptions {
|
|
238
|
-
|
|
211
|
+
quality?: "high" | "low";
|
|
239
212
|
}
|
|
240
213
|
|
|
241
214
|
export interface User {
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
215
|
+
id: number;
|
|
216
|
+
username: string;
|
|
217
|
+
followers_count: number;
|
|
218
|
+
track_count: number;
|
|
246
219
|
}
|
|
247
220
|
```
|
|
248
221
|
|
|
@@ -261,56 +234,53 @@ import SoundCloud from "@zibot/scdl";
|
|
|
261
234
|
const sc = new SoundCloud({ init: true });
|
|
262
235
|
|
|
263
236
|
async function save(url: string, file: string) {
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
.on("error", reject);
|
|
269
|
-
});
|
|
237
|
+
const stream = await sc.downloadTrack(url, { quality: "high" });
|
|
238
|
+
await new Promise<void>((resolve, reject) => {
|
|
239
|
+
stream.pipe(fs.createWriteStream(file)).on("finish", resolve).on("error", reject);
|
|
240
|
+
});
|
|
270
241
|
}
|
|
271
242
|
|
|
272
|
-
save("https://soundcloud.com/user/track", "output.
|
|
243
|
+
save("https://soundcloud.com/user/track", "output.ts");
|
|
273
244
|
```
|
|
274
245
|
|
|
275
246
|
### Basic search → pick first result → download
|
|
276
247
|
|
|
277
248
|
```ts
|
|
278
|
-
const [first] = await sc.
|
|
249
|
+
const [first] = await sc.search({ query: "deep house 2024", type: "tracks", limit: 1 });
|
|
279
250
|
if (first && "url" in first) {
|
|
280
|
-
|
|
281
|
-
|
|
251
|
+
const s = await sc.downloadTrack(first.url);
|
|
252
|
+
s.pipe(process.stdout);
|
|
282
253
|
}
|
|
283
254
|
```
|
|
284
255
|
|
|
285
256
|
### Paginate results
|
|
286
257
|
|
|
287
258
|
```ts
|
|
288
|
-
const page1 = await sc.
|
|
289
|
-
const page2 = await sc.
|
|
259
|
+
const page1 = await sc.search({ query: "vaporwave", type: "tracks", limit: 20, offset: 0 });
|
|
260
|
+
const page2 = await sc.search({ query: "vaporwave", type: "tracks", limit: 20, offset: 20 });
|
|
290
261
|
```
|
|
291
262
|
|
|
292
263
|
---
|
|
293
264
|
|
|
294
265
|
## Error Handling & Tips
|
|
295
266
|
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
267
|
+
- **Initialization:** If a method throws due to missing/expired `clientId`, call `await sc.init()` and retry.
|
|
268
|
+
- **Quality selection:** Not all tracks expose multiple transcodings; the library will fall back when needed.
|
|
269
|
+
- **Rate limits / 429:** Back off and retry with an exponential strategy.
|
|
270
|
+
- **Private/geo-restricted tracks:** Details/downloads may be unavailable.
|
|
271
|
+
- **Networking:** Wrap downloads with proper error and close handlers to avoid dangling file descriptors.
|
|
301
272
|
|
|
302
273
|
---
|
|
303
274
|
|
|
304
275
|
## FAQ
|
|
305
276
|
|
|
306
|
-
**Q: Can I use this in the browser?**
|
|
307
|
-
This package targets Node.js (it returns Node `Readable`). Browser use is not supported.
|
|
277
|
+
**Q: Can I use this in the browser?** This package targets Node.js (it returns Node `Readable`). Browser use is not supported.
|
|
308
278
|
|
|
309
|
-
**Q: What audio format do I get?**
|
|
310
|
-
|
|
279
|
+
**Q: What audio format do I get?** Whatever the selected transcoding provides (commonly progressive MP3 or HLS AAC). You may need
|
|
280
|
+
to remux/encode if you require a specific container/codec.
|
|
311
281
|
|
|
312
|
-
**Q: Do I need my own client ID?**
|
|
313
|
-
|
|
282
|
+
**Q: Do I need my own client ID?** The client auto-discovers a valid `clientId`. If discovery fails due to upstream changes,
|
|
283
|
+
update the package to the latest version.
|
|
314
284
|
|
|
315
285
|
---
|
|
316
286
|
|
|
@@ -318,9 +288,9 @@ The client auto-discovers a valid `clientId`. If discovery fails due to upstream
|
|
|
318
288
|
|
|
319
289
|
PRs and issues are welcome! Please include:
|
|
320
290
|
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
291
|
+
- A clear description of the change
|
|
292
|
+
- Repro steps (for bugs)
|
|
293
|
+
- Tests where possible
|
|
324
294
|
|
|
325
295
|
---
|
|
326
296
|
|
|
@@ -332,4 +302,5 @@ MIT © Zibot
|
|
|
332
302
|
|
|
333
303
|
## Disclaimer
|
|
334
304
|
|
|
335
|
-
This project is not affiliated with SoundCloud. Use responsibly and comply with all applicable laws and SoundCloud’s Terms of
|
|
305
|
+
This project is not affiliated with SoundCloud. Use responsibly and comply with all applicable laws and SoundCloud’s Terms of
|
|
306
|
+
Service.
|
package/index.d.ts
CHANGED
|
@@ -13,36 +13,172 @@ export interface DownloadOptions {
|
|
|
13
13
|
}
|
|
14
14
|
|
|
15
15
|
export interface Track {
|
|
16
|
+
artwork_url: string | null;
|
|
17
|
+
caption: string | null;
|
|
18
|
+
commentable: boolean;
|
|
19
|
+
comment_count: number;
|
|
20
|
+
created_at: string;
|
|
21
|
+
description: string | null;
|
|
22
|
+
downloadable: boolean;
|
|
23
|
+
download_count: number;
|
|
24
|
+
duration: number;
|
|
25
|
+
full_duration: number;
|
|
26
|
+
embeddable_by: string;
|
|
27
|
+
genre: string;
|
|
28
|
+
has_downloads_left: boolean;
|
|
16
29
|
id: number;
|
|
30
|
+
kind: string;
|
|
31
|
+
label_name: string | null;
|
|
32
|
+
last_modified: string;
|
|
33
|
+
license: string;
|
|
34
|
+
likes_count: number;
|
|
35
|
+
permalink: string;
|
|
36
|
+
permalink_url: string;
|
|
37
|
+
playback_count: number;
|
|
38
|
+
public: boolean;
|
|
39
|
+
publisher_metadata: PublisherMetadata | null;
|
|
40
|
+
purchase_title: string | null;
|
|
41
|
+
purchase_url: string | null;
|
|
42
|
+
release_date: string | null;
|
|
43
|
+
reposts_count: number;
|
|
44
|
+
secret_token: string | null;
|
|
45
|
+
sharing: string;
|
|
46
|
+
state: "finished" | "processing" | "failed" | string;
|
|
47
|
+
streamable: boolean;
|
|
48
|
+
tag_list: string;
|
|
17
49
|
title: string;
|
|
18
|
-
|
|
19
|
-
|
|
50
|
+
uri: string;
|
|
51
|
+
urn: string;
|
|
52
|
+
user_id: number;
|
|
53
|
+
visuals: any | null;
|
|
54
|
+
waveform_url: string;
|
|
55
|
+
display_date: string;
|
|
20
56
|
media: {
|
|
21
|
-
transcodings:
|
|
22
|
-
url: string;
|
|
23
|
-
format: { protocol: string; mime_type: string };
|
|
24
|
-
}[];
|
|
57
|
+
transcodings: Transcoding[];
|
|
25
58
|
};
|
|
59
|
+
station_urn: string;
|
|
60
|
+
station_permalink: string;
|
|
61
|
+
track_authorization: string;
|
|
62
|
+
monetization_model: string;
|
|
63
|
+
policy: string;
|
|
64
|
+
user: User;
|
|
26
65
|
}
|
|
27
66
|
|
|
28
|
-
export interface
|
|
67
|
+
export interface PublisherMetadata {
|
|
29
68
|
id: number;
|
|
30
|
-
|
|
31
|
-
|
|
69
|
+
urn: string;
|
|
70
|
+
contains_music: boolean;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export interface Transcoding {
|
|
74
|
+
url: string;
|
|
75
|
+
preset: string;
|
|
76
|
+
duration: number;
|
|
77
|
+
snipped: boolean;
|
|
78
|
+
format: {
|
|
79
|
+
protocol: string;
|
|
80
|
+
mime_type: string;
|
|
81
|
+
};
|
|
82
|
+
quality: string;
|
|
32
83
|
}
|
|
33
84
|
|
|
34
85
|
export interface User {
|
|
86
|
+
avatar_url: string;
|
|
87
|
+
city: string | null;
|
|
88
|
+
comments_count: number;
|
|
89
|
+
country_code: string | null;
|
|
90
|
+
created_at: string | null;
|
|
91
|
+
creator_subscriptions: any[];
|
|
92
|
+
creator_subscription: {
|
|
93
|
+
product: { id: string; [key: string]: any };
|
|
94
|
+
};
|
|
95
|
+
description: string | null;
|
|
96
|
+
followers_count: number;
|
|
97
|
+
followings_count: number;
|
|
98
|
+
first_name: string;
|
|
99
|
+
full_name: string;
|
|
100
|
+
groups_count: number;
|
|
35
101
|
id: number;
|
|
102
|
+
kind: string;
|
|
103
|
+
last_modified: string;
|
|
104
|
+
last_name: string;
|
|
105
|
+
likes_count: number;
|
|
106
|
+
playlist_likes_count: number;
|
|
107
|
+
permalink: string;
|
|
108
|
+
permalink_url: string;
|
|
109
|
+
playlist_count: number;
|
|
110
|
+
reposts_count: number | null;
|
|
111
|
+
track_count: number;
|
|
112
|
+
uri: string;
|
|
113
|
+
urn: string;
|
|
36
114
|
username: string;
|
|
37
|
-
|
|
115
|
+
verified: boolean;
|
|
116
|
+
visuals: {
|
|
117
|
+
urn: string;
|
|
118
|
+
enabled: boolean;
|
|
119
|
+
visuals: any[];
|
|
120
|
+
tracking: any | null;
|
|
121
|
+
} | null;
|
|
122
|
+
badges: {
|
|
123
|
+
pro: boolean;
|
|
124
|
+
creator_mid_tier: boolean;
|
|
125
|
+
pro_unlimited: boolean;
|
|
126
|
+
verified: boolean;
|
|
127
|
+
};
|
|
128
|
+
station_urn: string;
|
|
129
|
+
station_permalink: string;
|
|
130
|
+
date_of_birth: string | null;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export interface Playlist {
|
|
134
|
+
artwork_url: string | null;
|
|
135
|
+
created_at: string;
|
|
136
|
+
description: string | null;
|
|
137
|
+
duration: number;
|
|
138
|
+
embeddable_by: string;
|
|
139
|
+
genre: string;
|
|
140
|
+
id: number;
|
|
141
|
+
kind: "playlist" | string;
|
|
142
|
+
label_name: string | null;
|
|
143
|
+
last_modified: string;
|
|
144
|
+
license: string;
|
|
145
|
+
likes_count: number;
|
|
146
|
+
managed_by_feeds: boolean;
|
|
147
|
+
permalink: string;
|
|
148
|
+
permalink_url: string;
|
|
149
|
+
public: boolean;
|
|
150
|
+
purchase_title: string | null;
|
|
151
|
+
purchase_url: string | null;
|
|
152
|
+
release_date: string | null;
|
|
153
|
+
reposts_count: number;
|
|
154
|
+
secret_token: string | null;
|
|
155
|
+
sharing: string;
|
|
156
|
+
tag_list: string;
|
|
157
|
+
title: string;
|
|
158
|
+
uri: string;
|
|
159
|
+
user_id: number;
|
|
160
|
+
set_type: string;
|
|
161
|
+
is_album: boolean;
|
|
162
|
+
published_at: string | null;
|
|
163
|
+
display_date: string;
|
|
164
|
+
user: User;
|
|
165
|
+
tracks: Track[];
|
|
38
166
|
track_count: number;
|
|
39
167
|
}
|
|
40
168
|
|
|
41
169
|
declare class SoundCloud {
|
|
42
170
|
clientId: string | null;
|
|
43
171
|
apiBaseUrl: string;
|
|
172
|
+
appVersion: number;
|
|
44
173
|
|
|
45
|
-
constructor(options?: {
|
|
174
|
+
constructor(options?: {
|
|
175
|
+
init?: boolean;
|
|
176
|
+
autoInit: boolean;
|
|
177
|
+
apiBaseUrl: string;
|
|
178
|
+
timeout: number;
|
|
179
|
+
onClientId: null;
|
|
180
|
+
clientId: null;
|
|
181
|
+
});
|
|
46
182
|
|
|
47
183
|
/**
|
|
48
184
|
* Initialize the SoundCloud client to retrieve clientId.
|
|
@@ -52,7 +188,7 @@ declare class SoundCloud {
|
|
|
52
188
|
/**
|
|
53
189
|
* Search for tracks, playlists, or users on SoundCloud.
|
|
54
190
|
*/
|
|
55
|
-
|
|
191
|
+
search(options: SearchOptions): Promise<(Track | Playlist | User)[]>;
|
|
56
192
|
|
|
57
193
|
/**
|
|
58
194
|
* Retrieve detailed information about a single track.
|
package/index.js
CHANGED
|
@@ -6,11 +6,11 @@ const m3u8stream = require("m3u8stream");
|
|
|
6
6
|
class SoundCloud {
|
|
7
7
|
/**
|
|
8
8
|
* @param {Object} options
|
|
9
|
-
* @param {boolean} [options.autoInit=true]
|
|
9
|
+
* @param {boolean} [options.autoInit=true]
|
|
10
10
|
* @param {string} [options.apiBaseUrl="https://api-v2.soundcloud.com"]
|
|
11
11
|
* @param {number} [options.timeout=12_000]
|
|
12
|
-
* @param {(id:string)=>void} [options.onClientId]
|
|
13
|
-
* @param {string} [options.clientId]
|
|
12
|
+
* @param {(id:string)=>void} [options.onClientId]
|
|
13
|
+
* @param {string} [options.clientId]
|
|
14
14
|
*/
|
|
15
15
|
constructor(options = {}) {
|
|
16
16
|
const defaultOptions = {
|
|
@@ -23,6 +23,7 @@ class SoundCloud {
|
|
|
23
23
|
this.opts = { ...defaultOptions, ...options };
|
|
24
24
|
this.apiBaseUrl = this.opts.apiBaseUrl;
|
|
25
25
|
this.clientId = this.opts.clientId || null;
|
|
26
|
+
this.appVersion = null; // Will be fetched during init
|
|
26
27
|
|
|
27
28
|
this.http = axios.create({
|
|
28
29
|
timeout: this.opts.timeout,
|
|
@@ -40,16 +41,19 @@ class SoundCloud {
|
|
|
40
41
|
}
|
|
41
42
|
|
|
42
43
|
async ensureReady() {
|
|
43
|
-
if (this.clientId) return;
|
|
44
|
+
if (this.clientId && this.appVersion) return;
|
|
44
45
|
if (!this._initPromise) this._initPromise = this.init();
|
|
45
46
|
await this._initPromise;
|
|
46
47
|
}
|
|
47
48
|
|
|
48
49
|
async _getJson(url, { retries = 3, retryOn = [429, 500, 502, 503, 504] } = {}) {
|
|
50
|
+
const separator = url.includes("?") ? "&" : "?";
|
|
51
|
+
const finalUrl = this.appVersion ? `${url}${separator}app_version=${this.appVersion}` : url;
|
|
52
|
+
|
|
49
53
|
let lastErr;
|
|
50
54
|
for (let attempt = 0; attempt <= retries; attempt++) {
|
|
51
55
|
try {
|
|
52
|
-
const { data } = await this.http.get(
|
|
56
|
+
const { data } = await this.http.get(finalUrl);
|
|
53
57
|
return data;
|
|
54
58
|
} catch (err) {
|
|
55
59
|
lastErr = err;
|
|
@@ -64,45 +68,63 @@ class SoundCloud {
|
|
|
64
68
|
}
|
|
65
69
|
|
|
66
70
|
async init() {
|
|
67
|
-
if (this.clientId) return this.clientId;
|
|
71
|
+
if (this.clientId && this.appVersion) return this.clientId;
|
|
68
72
|
|
|
69
|
-
const
|
|
70
|
-
/client_id=([a-zA-Z0-9]{32})/g,
|
|
71
|
-
/client_id:"([a-zA-Z0-9]{32})"/,
|
|
72
|
-
/"client_id"\s*:\s*"([a-zA-Z0-9]{32})"/g,
|
|
73
|
+
const clientRegexes = [
|
|
74
|
+
/client_id=([a-zA-Z0-9]{32})/g,
|
|
75
|
+
/client_id:"([a-zA-Z0-9]{32})"/,
|
|
76
|
+
/"client_id"\s*:\s*"([a-zA-Z0-9]{32})"/g,
|
|
73
77
|
];
|
|
78
|
+
const versionRegex = /"app_version"\s*:\s*"([^"]+)"/;
|
|
79
|
+
|
|
80
|
+
const homeHtml = await this.http
|
|
81
|
+
.get("https://soundcloud.com")
|
|
82
|
+
.then((r) => r.data)
|
|
83
|
+
.catch(() => null);
|
|
84
|
+
|
|
85
|
+
// Attempt to extract app_version from home HTML first
|
|
86
|
+
if (homeHtml && versionRegex.test(homeHtml)) {
|
|
87
|
+
this.appVersion = homeHtml.match(versionRegex)[1];
|
|
88
|
+
}
|
|
74
89
|
|
|
75
|
-
const homeHtml = await this._getJson("https://soundcloud.com").catch(() => null);
|
|
76
90
|
const scriptUrls =
|
|
77
|
-
(typeof homeHtml === "string"
|
|
78
|
-
|
|
79
|
-
|
|
91
|
+
(typeof homeHtml === "string" ?
|
|
92
|
+
(homeHtml.match(/<script[^>]+src="([^"]+)"/g) || []).map((t) => t.match(/src="([^"]+)"/)?.[1]).filter(Boolean)
|
|
93
|
+
: []) || [];
|
|
80
94
|
|
|
81
95
|
const candidates = [
|
|
82
96
|
...scriptUrls.filter((u) => /sndcdn\.com|soundcloud\.com/.test(u)),
|
|
83
97
|
"https://a-v2.sndcdn.com/assets/1-ff6b3.js",
|
|
84
|
-
"https://a-v2.sndcdn.com/assets/2-ff6b3.js",
|
|
85
98
|
];
|
|
86
99
|
|
|
87
100
|
for (const url of candidates) {
|
|
88
101
|
try {
|
|
89
102
|
const res = await this.http.get(url, { responseType: "text" });
|
|
90
103
|
const text = res.data || "";
|
|
91
|
-
|
|
104
|
+
|
|
105
|
+
if (!this.appVersion && versionRegex.test(text)) {
|
|
106
|
+
this.appVersion = text.match(versionRegex)[1];
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
for (const re of clientRegexes) {
|
|
92
110
|
const m = re.exec(text);
|
|
93
111
|
if (m && m[1]) {
|
|
94
112
|
this.clientId = m[1];
|
|
95
113
|
if (typeof this.opts.onClientId === "function") this.opts.onClientId(this.clientId);
|
|
96
|
-
return this.clientId;
|
|
97
114
|
}
|
|
98
115
|
}
|
|
116
|
+
if (this.clientId && this.appVersion) break;
|
|
99
117
|
} catch {}
|
|
100
118
|
}
|
|
101
119
|
|
|
102
|
-
|
|
120
|
+
// Fallback app_version if not found
|
|
121
|
+
if (!this.appVersion) this.appVersion = Math.floor(Date.now() / 1000).toString();
|
|
122
|
+
if (!this.clientId) throw new Error("Không thể lấy client_id từ SoundCloud");
|
|
123
|
+
|
|
124
|
+
return this.clientId;
|
|
103
125
|
}
|
|
104
126
|
|
|
105
|
-
async
|
|
127
|
+
async search({ query, limit = 30, offset = 0, type = "all" }) {
|
|
106
128
|
await this.ensureReady();
|
|
107
129
|
const path = type === "all" ? "" : `/${type}`;
|
|
108
130
|
const url =
|
|
@@ -144,35 +166,59 @@ class SoundCloud {
|
|
|
144
166
|
return playlist;
|
|
145
167
|
}
|
|
146
168
|
|
|
147
|
-
async downloadTrack(
|
|
169
|
+
async downloadTrack(trackOrPlaylistUrl, options = {}) {
|
|
148
170
|
await this.ensureReady();
|
|
149
171
|
try {
|
|
150
|
-
|
|
172
|
+
let item = await this.fetchItem(trackOrPlaylistUrl);
|
|
151
173
|
|
|
152
|
-
|
|
153
|
-
|
|
174
|
+
let track;
|
|
175
|
+
if (item.kind === "playlist") {
|
|
176
|
+
console.log(`[SoundCloud] Đã nhận diện link Set/Playlist: ${item.title}`);
|
|
177
|
+
if (!item.tracks || item.tracks.length === 0) {
|
|
178
|
+
throw new Error("Playlist này không có bài hát nào.");
|
|
179
|
+
}
|
|
180
|
+
track = item.tracks[0];
|
|
181
|
+
|
|
182
|
+
if (!track.media) {
|
|
183
|
+
track = await this.getTrackDetails(track.permalink_url || track.id);
|
|
184
|
+
}
|
|
185
|
+
} else if (item.kind === "track") {
|
|
186
|
+
track = item;
|
|
187
|
+
} else {
|
|
188
|
+
throw new Error("URL không phải là bài hát hoặc playlist hợp lệ.");
|
|
154
189
|
}
|
|
155
|
-
|
|
156
|
-
|
|
190
|
+
|
|
191
|
+
if (track?.policy === "BLOCK" || track?.state === "blocked") {
|
|
192
|
+
throw new Error(`Bài hát "${track.title}" bị chặn.`);
|
|
157
193
|
}
|
|
158
194
|
|
|
159
|
-
const
|
|
160
|
-
if (!
|
|
161
|
-
|
|
162
|
-
const
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
195
|
+
const transcodings = this._getSortedTranscodings(track);
|
|
196
|
+
if (!transcodings.length) throw new Error("Không tìm thấy stream phù hợp cho bài này.");
|
|
197
|
+
|
|
198
|
+
for (const transcoding of transcodings) {
|
|
199
|
+
try {
|
|
200
|
+
const streamUrl = await this.getStreamUrl(transcoding.url);
|
|
201
|
+
if (transcoding.format?.protocol === "hls") {
|
|
202
|
+
return m3u8stream(streamUrl, {
|
|
203
|
+
requestOptions: {
|
|
204
|
+
headers: {
|
|
205
|
+
"User-Agent": this.http.defaults.headers["User-Agent"],
|
|
206
|
+
Referer: "https://soundcloud.com/",
|
|
207
|
+
},
|
|
208
|
+
},
|
|
209
|
+
...options,
|
|
210
|
+
});
|
|
211
|
+
} else {
|
|
212
|
+
const res = await this.http.get(streamUrl, { responseType: "stream" });
|
|
213
|
+
return res.data;
|
|
214
|
+
}
|
|
215
|
+
} catch (err) {
|
|
216
|
+
continue; // Thử định dạng tiếp theo nếu định dạng này lỗi
|
|
217
|
+
}
|
|
173
218
|
}
|
|
219
|
+
throw new Error("Không thể khởi tạo luồng tải cho tất cả định dạng.");
|
|
174
220
|
} catch (e) {
|
|
175
|
-
console.error("Failed to download
|
|
221
|
+
console.error("Failed to download:", e?.message || e);
|
|
176
222
|
return null;
|
|
177
223
|
}
|
|
178
224
|
}
|
|
@@ -204,11 +250,6 @@ class SoundCloud {
|
|
|
204
250
|
);
|
|
205
251
|
return results.flat();
|
|
206
252
|
} catch (error) {
|
|
207
|
-
console.error("Failed to fetch tracks by IDs:", {
|
|
208
|
-
clientId: this.clientId,
|
|
209
|
-
status: error?.response?.status,
|
|
210
|
-
error: error?.response?.data || error?.message,
|
|
211
|
-
});
|
|
212
253
|
throw new Error("Failed to fetch tracks by IDs");
|
|
213
254
|
}
|
|
214
255
|
}
|
|
@@ -230,26 +271,41 @@ class SoundCloud {
|
|
|
230
271
|
if (!data?.url) throw new Error("No stream URL in response (after refresh)");
|
|
231
272
|
return data.url;
|
|
232
273
|
}
|
|
233
|
-
throw
|
|
274
|
+
throw error;
|
|
234
275
|
}
|
|
235
276
|
}
|
|
236
277
|
|
|
237
|
-
|
|
238
|
-
_pickBestTranscoding(track) {
|
|
278
|
+
_getSortedTranscodings(track) {
|
|
239
279
|
const list = Array.isArray(track?.media?.transcodings) ? track.media.transcodings : [];
|
|
240
|
-
if (!list.length) return null;
|
|
241
280
|
|
|
242
281
|
const score = (t) => {
|
|
282
|
+
let s = 0;
|
|
243
283
|
const proto = t?.format?.protocol;
|
|
244
284
|
const mime = t?.format?.mime_type || "";
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
285
|
+
const isLegacy = t?.is_legacy_transcoding;
|
|
286
|
+
|
|
287
|
+
// Priority 1: Modern transcodings (aac_160k, etc.)
|
|
288
|
+
if (isLegacy === false) s += 1000;
|
|
289
|
+
|
|
290
|
+
// Priority 2: Protocol (HLS generally preferred for performance)
|
|
291
|
+
if (proto === "hls") s += 100;
|
|
292
|
+
else if (proto === "progressive") s += 50;
|
|
293
|
+
|
|
294
|
+
// Priority 3: Codec quality
|
|
295
|
+
if (mime.includes("opus")) s += 30;
|
|
296
|
+
if (mime.includes("mp4") || mime.includes("aac")) s += 25;
|
|
297
|
+
if (mime.includes("mpeg")) s += 10;
|
|
298
|
+
|
|
299
|
+
return s;
|
|
249
300
|
};
|
|
250
301
|
|
|
251
|
-
return [...list].sort((a, b) => score(b) - score(a))
|
|
302
|
+
return [...list].sort((a, b) => score(b) - score(a));
|
|
252
303
|
}
|
|
304
|
+
|
|
305
|
+
_pickBestTranscoding(track) {
|
|
306
|
+
return this._getSortedTranscodings(track)[0] || null;
|
|
307
|
+
}
|
|
308
|
+
|
|
253
309
|
async _resolveTrackId(input) {
|
|
254
310
|
await this.ensureReady();
|
|
255
311
|
if (!input) throw new Error("Missing track identifier");
|
|
@@ -263,23 +319,17 @@ class SoundCloud {
|
|
|
263
319
|
return item.id;
|
|
264
320
|
}
|
|
265
321
|
|
|
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
322
|
async getRelatedTracks(track, { limit = 20, offset = 0 } = {}) {
|
|
275
323
|
await this.ensureReady();
|
|
276
324
|
const id = await this._resolveTrackId(track);
|
|
277
|
-
|
|
278
325
|
const url = `${this.apiBaseUrl}/tracks/${id}/related` + `?limit=${limit}&offset=${offset}&client_id=${this.clientId}`;
|
|
279
326
|
|
|
280
327
|
try {
|
|
281
328
|
const data = await this._getJson(url);
|
|
282
|
-
const collection =
|
|
329
|
+
const collection =
|
|
330
|
+
Array.isArray(data?.collection) ? data.collection
|
|
331
|
+
: Array.isArray(data) ? data
|
|
332
|
+
: [];
|
|
283
333
|
return collection.filter((t) => t?.permalink_url && t?.title && t?.duration);
|
|
284
334
|
} catch (e) {
|
|
285
335
|
return [];
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zibot/scdl",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.1.1",
|
|
4
4
|
"description": "Soucloud download",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"types": "index.d.ts",
|
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
},
|
|
26
26
|
"homepage": "https://github.com/ZiProject/scdl#readme",
|
|
27
27
|
"dependencies": {
|
|
28
|
-
"axios": "^1.
|
|
28
|
+
"axios": "^1.15.0",
|
|
29
29
|
"events": "^3.3.0",
|
|
30
30
|
"m3u8stream": "^0.8.6"
|
|
31
31
|
}
|
package/test.js
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
const SoundCloud = require("./index.js");
|
|
2
|
+
const fs = require("node:fs");
|
|
3
|
+
const { pipeline } = require("node:stream/promises");
|
|
4
|
+
|
|
5
|
+
const sc = new SoundCloud();
|
|
6
|
+
|
|
7
|
+
(async () => {
|
|
8
|
+
try {
|
|
9
|
+
const Search = await sc.getPlaylistDetails(
|
|
10
|
+
"https://soundcloud.com/atniexvnk0ry/sets/c2nk5mokbmad?si=469007ce80c5418a93f7df6d8a431c51&utm_source=clipboard&utm_medium=text&utm_campaign=social_sharing",
|
|
11
|
+
);
|
|
12
|
+
console.log("Thông tin bài hát:", Search);
|
|
13
|
+
// const getRelatedTracks = await sc.getRelatedTracks(Search.uri);
|
|
14
|
+
// console.log("Các bài hát liên quan:", getRelatedTracks);
|
|
15
|
+
// console.log("Đang khởi tạo và lấy stream...");
|
|
16
|
+
// const url = "https://soundcloud.com/jar-chow-794199690/etoilesong-by-vermementomori-ost";
|
|
17
|
+
|
|
18
|
+
// const stream = await sc.downloadTrack(url);
|
|
19
|
+
|
|
20
|
+
// if (!stream) {
|
|
21
|
+
// console.error("Không lấy được stream.");
|
|
22
|
+
// return;
|
|
23
|
+
// }
|
|
24
|
+
|
|
25
|
+
// const filename = "track.ts"; // HLS thường là định dạng MPEG-TS
|
|
26
|
+
// const writeStream = fs.createWriteStream(filename);
|
|
27
|
+
|
|
28
|
+
// console.log("Đang tải dữ liệu...");
|
|
29
|
+
|
|
30
|
+
// // Theo dõi tiến trình (optional)
|
|
31
|
+
// let downloaded = 0;
|
|
32
|
+
// stream.on("data", (chunk) => {
|
|
33
|
+
// downloaded += chunk.length;
|
|
34
|
+
// process.stdout.write(`\rĐã tải: ${(downloaded / 1024).toFixed(2)} KB`);
|
|
35
|
+
// });
|
|
36
|
+
|
|
37
|
+
// await pipeline(stream, writeStream);
|
|
38
|
+
|
|
39
|
+
// console.log(`\n✅ Tải xong! File lưu tại: ${filename}`);
|
|
40
|
+
} catch (err) {
|
|
41
|
+
console.error("\n❌ Lỗi trong quá trình tải:", err.message);
|
|
42
|
+
}
|
|
43
|
+
})();
|