@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.
Files changed (5) hide show
  1. package/README.md +73 -102
  2. package/index.d.ts +148 -12
  3. package/index.js +114 -64
  4. package/package.json +2 -2
  5. 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 streams.
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
- * **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
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 copyrighted content without permission.
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
- // Search tracks
39
- const results = await sc.searchTracks({ query: "lofi hip hop", limit: 5, type: "tracks" });
40
- console.log(results);
40
+ // Search tracks
41
+ const results = await sc.search({ query: "lofi hip hop", limit: 5, type: "tracks" });
42
+ console.log(results);
41
43
 
42
- // Track details
43
- const track = await sc.getTrackDetails("https://soundcloud.com/user/track-slug");
44
- console.log(track.title, track.user.username);
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
- // 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")
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
- // Related tracks
51
- const related = await sc.getRelatedTracks(track.id, { limit: 10 });
52
- console.log(related.map(t => t.title));
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
- const stream = await sc.downloadTrack("https://soundcloud.com/user/track-slug");
66
- stream.pipe(fs.createWriteStream("track.mp3"));
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 the client ID.
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
- * `options.init?: boolean` – if `true`, calls `init()` internally.
98
+ - `options.init?: boolean` – if `true`, calls `init()` internally.
96
99
 
97
100
  **Properties**
98
101
 
99
- * `clientId: string | null` – resolved after `init()`
100
- * `apiBaseUrl: string` – internal base URL used for API calls
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
- ### `searchTracks(options: SearchOptions): Promise<(Track | Playlist | User)[]>`
113
+ ### `search(options: SearchOptions): Promise<(Track | Playlist | User)[]>`
111
114
 
112
115
  Search SoundCloud.
113
116
 
114
117
  **Parameters – `SearchOptions`**
115
118
 
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"`)
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.searchTracks({ query: "ambient study", type: "tracks", limit: 10 });
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.searchTracks({ query: "chill", type: "all", limit: 5, offset: 5 });
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
- id: t.id,
141
- title: t.title,
142
- by: t.user.username,
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
- * `quality?: "high" | "low"` – choose available transcoding (default implementation prefers higher quality when available)
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
- read.pipe(fs.createWriteStream("track.mp3"))
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
- query: string;
232
- limit?: number;
233
- offset?: number;
234
- type?: "all" | "tracks" | "playlists" | "users";
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
- quality?: "high" | "low";
211
+ quality?: "high" | "low";
239
212
  }
240
213
 
241
214
  export interface User {
242
- id: number;
243
- username: string;
244
- followers_count: number;
245
- track_count: number;
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
- 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
- });
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.mp3");
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.searchTracks({ query: "deep house 2024", type: "tracks", limit: 1 });
249
+ const [first] = await sc.search({ query: "deep house 2024", type: "tracks", limit: 1 });
279
250
  if (first && "url" in first) {
280
- const s = await sc.downloadTrack(first.url);
281
- s.pipe(process.stdout);
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.searchTracks({ query: "vaporwave", type: "tracks", limit: 20, offset: 0 });
289
- const page2 = await sc.searchTracks({ query: "vaporwave", type: "tracks", limit: 20, offset: 20 });
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
- * **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.
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
- Whatever the selected transcoding provides (commonly progressive MP3 or HLS AAC). You may need to remux/encode if you require a specific container/codec.
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
- The client auto-discovers a valid `clientId`. If discovery fails due to upstream changes, update the package to the latest version.
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
- * A clear description of the change
322
- * Repro steps (for bugs)
323
- * Tests where possible
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 Service.
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
- url: string;
19
- user: { id: number; username: string };
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 Playlist {
67
+ export interface PublisherMetadata {
29
68
  id: number;
30
- title: string;
31
- tracks: Track[];
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
- followers_count: number;
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?: { init?: boolean });
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
- searchTracks(options: SearchOptions): Promise<(Track | Playlist | User)[]>;
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] - tự động lấy clientId
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] - 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ệ
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(url);
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 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
+ 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
- ? (homeHtml.match(/<script[^>]+src="([^"]+)"/g) || []).map((t) => t.match(/src="([^"]+)"/)?.[1]).filter(Boolean)
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
- for (const re of regexes) {
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
- throw new Error("Không thể lấy client_id từ SoundCloud");
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 searchTracks({ query, limit = 30, offset = 0, type = "all" }) {
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(trackUrl, options = {}) {
169
+ async downloadTrack(trackOrPlaylistUrl, options = {}) {
148
170
  await this.ensureReady();
149
171
  try {
150
- const track = await this.getTrackDetails(trackUrl);
172
+ let item = await this.fetchItem(trackOrPlaylistUrl);
151
173
 
152
- if (track?.policy === "BLOCK" || track?.state === "blocked") {
153
- throw new Error("Track bị chặn (policy/geo).");
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
- if (track?.has_downloads === false && track?.streamable === false) {
156
- throw new Error("Track không cho phép stream.");
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 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;
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 track:", e?.message || e);
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 new Error("Failed to fetch stream URL");
274
+ throw error;
234
275
  }
235
276
  }
236
277
 
237
- /** pick transcoding: HLS (opus > mp3), fallback progressive mp3 */
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
- 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;
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))[0];
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 = Array.isArray(data?.collection) ? data.collection : Array.isArray(data) ? data : [];
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.0.6",
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.13.2",
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
+ })();