@zibot/scdl 0.0.5 → 0.1.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/.prettierrc +21 -0
- package/README.md +272 -55
- package/index.js +113 -62
- package/package.json +32 -35
- package/test.js +37 -0
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.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,41 +68,60 @@ 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
|
-
/
|
|
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,
|
|
72
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
|
+
}
|
|
73
89
|
|
|
74
|
-
const homeHtml = await this._getJson("https://soundcloud.com").catch(() => null);
|
|
75
90
|
const scriptUrls =
|
|
76
|
-
(typeof homeHtml === "string"
|
|
77
|
-
|
|
78
|
-
|
|
91
|
+
(typeof homeHtml === "string" ?
|
|
92
|
+
(homeHtml.match(/<script[^>]+src="([^"]+)"/g) || []).map((t) => t.match(/src="([^"]+)"/)?.[1]).filter(Boolean)
|
|
93
|
+
: []) || [];
|
|
79
94
|
|
|
80
95
|
const candidates = [
|
|
81
96
|
...scriptUrls.filter((u) => /sndcdn\.com|soundcloud\.com/.test(u)),
|
|
82
97
|
"https://a-v2.sndcdn.com/assets/1-ff6b3.js",
|
|
83
|
-
"https://a-v2.sndcdn.com/assets/2-ff6b3.js",
|
|
84
98
|
];
|
|
85
99
|
|
|
86
100
|
for (const url of candidates) {
|
|
87
101
|
try {
|
|
88
102
|
const res = await this.http.get(url, { responseType: "text" });
|
|
89
103
|
const text = res.data || "";
|
|
90
|
-
|
|
104
|
+
|
|
105
|
+
if (!this.appVersion && versionRegex.test(text)) {
|
|
106
|
+
this.appVersion = text.match(versionRegex)[1];
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
for (const re of clientRegexes) {
|
|
91
110
|
const m = re.exec(text);
|
|
92
111
|
if (m && m[1]) {
|
|
93
112
|
this.clientId = m[1];
|
|
94
113
|
if (typeof this.opts.onClientId === "function") this.opts.onClientId(this.clientId);
|
|
95
|
-
return this.clientId;
|
|
96
114
|
}
|
|
97
115
|
}
|
|
116
|
+
if (this.clientId && this.appVersion) break;
|
|
98
117
|
} catch {}
|
|
99
118
|
}
|
|
100
119
|
|
|
101
|
-
|
|
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;
|
|
102
125
|
}
|
|
103
126
|
|
|
104
127
|
async searchTracks({ query, limit = 30, offset = 0, type = "all" }) {
|
|
@@ -143,35 +166,59 @@ class SoundCloud {
|
|
|
143
166
|
return playlist;
|
|
144
167
|
}
|
|
145
168
|
|
|
146
|
-
async downloadTrack(
|
|
169
|
+
async downloadTrack(trackOrPlaylistUrl, options = {}) {
|
|
147
170
|
await this.ensureReady();
|
|
148
171
|
try {
|
|
149
|
-
|
|
172
|
+
let item = await this.fetchItem(trackOrPlaylistUrl);
|
|
150
173
|
|
|
151
|
-
|
|
152
|
-
|
|
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ệ.");
|
|
153
189
|
}
|
|
154
|
-
|
|
155
|
-
|
|
190
|
+
|
|
191
|
+
if (track?.policy === "BLOCK" || track?.state === "blocked") {
|
|
192
|
+
throw new Error(`Bài hát "${track.title}" bị chặn.`);
|
|
156
193
|
}
|
|
157
194
|
|
|
158
|
-
const
|
|
159
|
-
if (!
|
|
160
|
-
|
|
161
|
-
const
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
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
|
+
}
|
|
172
218
|
}
|
|
219
|
+
throw new Error("Không thể khởi tạo luồng tải cho tất cả định dạng.");
|
|
173
220
|
} catch (e) {
|
|
174
|
-
console.error("Failed to download
|
|
221
|
+
console.error("Failed to download:", e?.message || e);
|
|
175
222
|
return null;
|
|
176
223
|
}
|
|
177
224
|
}
|
|
@@ -203,11 +250,6 @@ class SoundCloud {
|
|
|
203
250
|
);
|
|
204
251
|
return results.flat();
|
|
205
252
|
} catch (error) {
|
|
206
|
-
console.error("Failed to fetch tracks by IDs:", {
|
|
207
|
-
clientId: this.clientId,
|
|
208
|
-
status: error?.response?.status,
|
|
209
|
-
error: error?.response?.data || error?.message,
|
|
210
|
-
});
|
|
211
253
|
throw new Error("Failed to fetch tracks by IDs");
|
|
212
254
|
}
|
|
213
255
|
}
|
|
@@ -229,26 +271,41 @@ class SoundCloud {
|
|
|
229
271
|
if (!data?.url) throw new Error("No stream URL in response (after refresh)");
|
|
230
272
|
return data.url;
|
|
231
273
|
}
|
|
232
|
-
throw
|
|
274
|
+
throw error;
|
|
233
275
|
}
|
|
234
276
|
}
|
|
235
277
|
|
|
236
|
-
|
|
237
|
-
_pickBestTranscoding(track) {
|
|
278
|
+
_getSortedTranscodings(track) {
|
|
238
279
|
const list = Array.isArray(track?.media?.transcodings) ? track.media.transcodings : [];
|
|
239
|
-
if (!list.length) return null;
|
|
240
280
|
|
|
241
281
|
const score = (t) => {
|
|
282
|
+
let s = 0;
|
|
242
283
|
const proto = t?.format?.protocol;
|
|
243
284
|
const mime = t?.format?.mime_type || "";
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
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;
|
|
248
300
|
};
|
|
249
301
|
|
|
250
|
-
return [...list].sort((a, b) => score(b) - score(a))
|
|
302
|
+
return [...list].sort((a, b) => score(b) - score(a));
|
|
251
303
|
}
|
|
304
|
+
|
|
305
|
+
_pickBestTranscoding(track) {
|
|
306
|
+
return this._getSortedTranscodings(track)[0] || null;
|
|
307
|
+
}
|
|
308
|
+
|
|
252
309
|
async _resolveTrackId(input) {
|
|
253
310
|
await this.ensureReady();
|
|
254
311
|
if (!input) throw new Error("Missing track identifier");
|
|
@@ -262,23 +319,17 @@ class SoundCloud {
|
|
|
262
319
|
return item.id;
|
|
263
320
|
}
|
|
264
321
|
|
|
265
|
-
/**
|
|
266
|
-
* Lấy danh sách related tracks cho một track (URL hoặc ID)
|
|
267
|
-
* @param {string|number} track - track URL hoặc track ID
|
|
268
|
-
* @param {object} opts
|
|
269
|
-
* @param {number} [opts.limit=20]
|
|
270
|
-
* @param {number} [opts.offset=0]
|
|
271
|
-
* @returns {Promise<Array>} danh sách track tương tự
|
|
272
|
-
*/
|
|
273
322
|
async getRelatedTracks(track, { limit = 20, offset = 0 } = {}) {
|
|
274
323
|
await this.ensureReady();
|
|
275
324
|
const id = await this._resolveTrackId(track);
|
|
276
|
-
|
|
277
325
|
const url = `${this.apiBaseUrl}/tracks/${id}/related` + `?limit=${limit}&offset=${offset}&client_id=${this.clientId}`;
|
|
278
326
|
|
|
279
327
|
try {
|
|
280
328
|
const data = await this._getJson(url);
|
|
281
|
-
const collection =
|
|
329
|
+
const collection =
|
|
330
|
+
Array.isArray(data?.collection) ? data.collection
|
|
331
|
+
: Array.isArray(data) ? data
|
|
332
|
+
: [];
|
|
282
333
|
return collection.filter((t) => t?.permalink_url && t?.title && t?.duration);
|
|
283
334
|
} catch (e) {
|
|
284
335
|
return [];
|
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.1.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/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
|
+
}
|
package/test.js
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
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
|
+
console.log("Đang khởi tạo và lấy stream...");
|
|
10
|
+
const url = "https://soundcloud.com/jar-chow-794199690/etoilesong-by-vermementomori-ost";
|
|
11
|
+
|
|
12
|
+
const stream = await sc.downloadTrack(url);
|
|
13
|
+
|
|
14
|
+
if (!stream) {
|
|
15
|
+
console.error("Không lấy được stream.");
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const filename = "track.ts"; // HLS thường là định dạng MPEG-TS
|
|
20
|
+
const writeStream = fs.createWriteStream(filename);
|
|
21
|
+
|
|
22
|
+
console.log("Đang tải dữ liệu...");
|
|
23
|
+
|
|
24
|
+
// Theo dõi tiến trình (optional)
|
|
25
|
+
let downloaded = 0;
|
|
26
|
+
stream.on("data", (chunk) => {
|
|
27
|
+
downloaded += chunk.length;
|
|
28
|
+
process.stdout.write(`\rĐã tải: ${(downloaded / 1024).toFixed(2)} KB`);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
await pipeline(stream, writeStream);
|
|
32
|
+
|
|
33
|
+
console.log(`\n✅ Tải xong! File lưu tại: ${filename}`);
|
|
34
|
+
} catch (err) {
|
|
35
|
+
console.error("\n❌ Lỗi trong quá trình tải:", err.message);
|
|
36
|
+
}
|
|
37
|
+
})();
|