@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.
- package/index.js +113 -63
- package/package.json +1 -1
- 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]
|
|
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,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
|
|
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
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(
|
|
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
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
|
+
})();
|