@zibot/scdl 0.0.6 → 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.
Files changed (3) hide show
  1. package/index.js +113 -63
  2. package/package.json +1 -1
  3. package/test.js +37 -0
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,42 +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 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
127
  async searchTracks({ query, limit = 30, offset = 0, type = "all" }) {
@@ -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.0",
4
4
  "description": "Soucloud download",
5
5
  "main": "index.js",
6
6
  "types": "index.d.ts",
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
+ })();