@zibot/scdl 0.0.3 → 0.0.5
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.d.ts +10 -5
- package/index.js +228 -75
- package/package.json +1 -1
package/index.d.ts
CHANGED
|
@@ -1,18 +1,18 @@
|
|
|
1
1
|
import { Readable } from "stream";
|
|
2
2
|
|
|
3
3
|
// Types
|
|
4
|
-
interface SearchOptions {
|
|
4
|
+
export interface SearchOptions {
|
|
5
5
|
query: string;
|
|
6
6
|
limit?: number;
|
|
7
7
|
offset?: number;
|
|
8
8
|
type?: "all" | "tracks" | "playlists" | "users";
|
|
9
9
|
}
|
|
10
10
|
|
|
11
|
-
interface DownloadOptions {
|
|
11
|
+
export interface DownloadOptions {
|
|
12
12
|
quality?: "high" | "low";
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
-
interface Track {
|
|
15
|
+
export interface Track {
|
|
16
16
|
id: number;
|
|
17
17
|
title: string;
|
|
18
18
|
url: string;
|
|
@@ -25,13 +25,13 @@ interface Track {
|
|
|
25
25
|
};
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
-
interface Playlist {
|
|
28
|
+
export interface Playlist {
|
|
29
29
|
id: number;
|
|
30
30
|
title: string;
|
|
31
31
|
tracks: Track[];
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
-
interface User {
|
|
34
|
+
export interface User {
|
|
35
35
|
id: number;
|
|
36
36
|
username: string;
|
|
37
37
|
followers_count: number;
|
|
@@ -68,6 +68,11 @@ declare class SoundCloud {
|
|
|
68
68
|
* Download a track as a stream.
|
|
69
69
|
*/
|
|
70
70
|
downloadTrack(url: string, options?: DownloadOptions): Promise<Readable>;
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Get related tracks for a given track (by URL or ID).
|
|
74
|
+
*/
|
|
75
|
+
getRelatedTracks(track: string | number, opts?: { limit?: number; offset?: number }): Promise<Track[]>;
|
|
71
76
|
}
|
|
72
77
|
|
|
73
78
|
export = SoundCloud;
|
package/index.js
CHANGED
|
@@ -1,136 +1,289 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
1
3
|
const axios = require("axios");
|
|
2
4
|
const m3u8stream = require("m3u8stream");
|
|
3
5
|
|
|
4
6
|
class SoundCloud {
|
|
7
|
+
/**
|
|
8
|
+
* @param {Object} options
|
|
9
|
+
* @param {boolean} [options.autoInit=true] - tự động lấy clientId
|
|
10
|
+
* @param {string} [options.apiBaseUrl="https://api-v2.soundcloud.com"]
|
|
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ệ
|
|
14
|
+
*/
|
|
5
15
|
constructor(options = {}) {
|
|
6
|
-
const defaultOptions = {
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
16
|
+
const defaultOptions = {
|
|
17
|
+
autoInit: true,
|
|
18
|
+
apiBaseUrl: "https://api-v2.soundcloud.com",
|
|
19
|
+
timeout: 12_000,
|
|
20
|
+
onClientId: null,
|
|
21
|
+
clientId: null,
|
|
22
|
+
};
|
|
23
|
+
this.opts = { ...defaultOptions, ...options };
|
|
24
|
+
this.apiBaseUrl = this.opts.apiBaseUrl;
|
|
25
|
+
this.clientId = this.opts.clientId || null;
|
|
26
|
+
|
|
27
|
+
this.http = axios.create({
|
|
28
|
+
timeout: this.opts.timeout,
|
|
29
|
+
headers: {
|
|
30
|
+
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124 Safari/537.36",
|
|
31
|
+
Accept: "application/json, text/javascript, */*; q=0.01",
|
|
32
|
+
Referer: "https://soundcloud.com/",
|
|
33
|
+
},
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
this._initPromise = null;
|
|
37
|
+
if (this.opts.autoInit && !this.clientId) {
|
|
38
|
+
this._initPromise = this.init();
|
|
39
|
+
}
|
|
11
40
|
}
|
|
12
41
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
for (
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
42
|
+
async ensureReady() {
|
|
43
|
+
if (this.clientId) return;
|
|
44
|
+
if (!this._initPromise) this._initPromise = this.init();
|
|
45
|
+
await this._initPromise;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async _getJson(url, { retries = 3, retryOn = [429, 500, 502, 503, 504] } = {}) {
|
|
49
|
+
let lastErr;
|
|
50
|
+
for (let attempt = 0; attempt <= retries; attempt++) {
|
|
51
|
+
try {
|
|
52
|
+
const { data } = await this.http.get(url);
|
|
53
|
+
return data;
|
|
54
|
+
} catch (err) {
|
|
55
|
+
lastErr = err;
|
|
56
|
+
const status = err?.response?.status;
|
|
57
|
+
const shouldRetry = retryOn.includes(status) || err.code === "ECONNABORTED";
|
|
58
|
+
if (!shouldRetry || attempt === retries) break;
|
|
59
|
+
const delay = 300 * 2 ** attempt + Math.floor(Math.random() * 150);
|
|
60
|
+
await new Promise((r) => setTimeout(r, delay));
|
|
27
61
|
}
|
|
28
62
|
}
|
|
63
|
+
throw lastErr;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async init() {
|
|
67
|
+
if (this.clientId) return this.clientId;
|
|
68
|
+
|
|
69
|
+
const regexes = [
|
|
70
|
+
/client_id=([a-zA-Z0-9]{32})/g, // client_id=XXXXXXXX...
|
|
71
|
+
/"client_id"\s*:\s*"([a-zA-Z0-9]{32})"/g, // "client_id":"XXXXXXXX..."
|
|
72
|
+
];
|
|
73
|
+
|
|
74
|
+
const homeHtml = await this._getJson("https://soundcloud.com").catch(() => null);
|
|
75
|
+
const scriptUrls =
|
|
76
|
+
(typeof homeHtml === "string"
|
|
77
|
+
? (homeHtml.match(/<script[^>]+src="([^"]+)"/g) || []).map((t) => t.match(/src="([^"]+)"/)?.[1]).filter(Boolean)
|
|
78
|
+
: []) || [];
|
|
79
|
+
|
|
80
|
+
const candidates = [
|
|
81
|
+
...scriptUrls.filter((u) => /sndcdn\.com|soundcloud\.com/.test(u)),
|
|
82
|
+
"https://a-v2.sndcdn.com/assets/1-ff6b3.js",
|
|
83
|
+
"https://a-v2.sndcdn.com/assets/2-ff6b3.js",
|
|
84
|
+
];
|
|
85
|
+
|
|
86
|
+
for (const url of candidates) {
|
|
87
|
+
try {
|
|
88
|
+
const res = await this.http.get(url, { responseType: "text" });
|
|
89
|
+
const text = res.data || "";
|
|
90
|
+
for (const re of regexes) {
|
|
91
|
+
const m = re.exec(text);
|
|
92
|
+
if (m && m[1]) {
|
|
93
|
+
this.clientId = m[1];
|
|
94
|
+
if (typeof this.opts.onClientId === "function") this.opts.onClientId(this.clientId);
|
|
95
|
+
return this.clientId;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
} catch {}
|
|
99
|
+
}
|
|
29
100
|
|
|
30
|
-
throw new Error("
|
|
101
|
+
throw new Error("Không thể lấy client_id từ SoundCloud");
|
|
31
102
|
}
|
|
32
103
|
|
|
33
|
-
// Search SoundCloud
|
|
34
104
|
async searchTracks({ query, limit = 30, offset = 0, type = "all" }) {
|
|
105
|
+
await this.ensureReady();
|
|
35
106
|
const path = type === "all" ? "" : `/${type}`;
|
|
36
|
-
const url =
|
|
37
|
-
|
|
38
|
-
|
|
107
|
+
const url =
|
|
108
|
+
`${this.apiBaseUrl}/search${path}` +
|
|
109
|
+
`?q=${encodeURIComponent(query)}` +
|
|
110
|
+
`&limit=${limit}&offset=${offset}` +
|
|
111
|
+
`&access=playable&client_id=${this.clientId}`;
|
|
39
112
|
try {
|
|
40
|
-
const
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
return data.collection.filter((track) => {
|
|
47
|
-
if (!track.permalink_url || !track.title || !track.duration) return false;
|
|
48
|
-
return true;
|
|
49
|
-
});
|
|
50
|
-
} catch (error) {
|
|
51
|
-
console.error("Search error:", error.message || error);
|
|
113
|
+
const data = await this._getJson(url);
|
|
114
|
+
const collection = Array.isArray(data?.collection) ? data.collection : [];
|
|
115
|
+
return collection.filter((t) => t?.permalink_url && t?.title && t?.duration);
|
|
116
|
+
} catch (e) {
|
|
52
117
|
throw new Error("Search failed");
|
|
53
118
|
}
|
|
54
119
|
}
|
|
55
120
|
|
|
56
|
-
// Get track details
|
|
57
121
|
async getTrackDetails(trackUrl) {
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
}
|
|
122
|
+
await this.ensureReady();
|
|
123
|
+
const item = await this.fetchItem(trackUrl);
|
|
124
|
+
if (item?.kind !== "track") throw new Error("Invalid track URL");
|
|
125
|
+
return item;
|
|
63
126
|
}
|
|
64
127
|
|
|
65
|
-
// Get playlist details
|
|
66
128
|
async getPlaylistDetails(playlistUrl) {
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
129
|
+
await this.ensureReady();
|
|
130
|
+
const playlist = await this.fetchItem(playlistUrl);
|
|
131
|
+
if (playlist?.kind !== "playlist") throw new Error("Invalid playlist URL");
|
|
70
132
|
|
|
71
|
-
|
|
72
|
-
|
|
133
|
+
const tracks = Array.isArray(playlist.tracks) ? playlist.tracks : [];
|
|
134
|
+
const loaded = tracks.filter((t) => t?.title);
|
|
135
|
+
const unloadedIds = tracks.filter((t) => !t?.title && t?.id).map((t) => t.id);
|
|
73
136
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
return playlist;
|
|
80
|
-
} catch (error) {
|
|
81
|
-
throw new Error("Invalid playlist URL");
|
|
137
|
+
if (unloadedIds.length) {
|
|
138
|
+
const more = await this.fetchTracksByIds(unloadedIds);
|
|
139
|
+
playlist.tracks = loaded.concat(more);
|
|
140
|
+
} else {
|
|
141
|
+
playlist.tracks = loaded;
|
|
82
142
|
}
|
|
143
|
+
return playlist;
|
|
83
144
|
}
|
|
84
145
|
|
|
85
|
-
// Download track stream
|
|
86
146
|
async downloadTrack(trackUrl, options = {}) {
|
|
147
|
+
await this.ensureReady();
|
|
87
148
|
try {
|
|
88
149
|
const track = await this.getTrackDetails(trackUrl);
|
|
89
|
-
const transcoding = track?.media?.transcodings?.find((t) => t.format.protocol === "hls");
|
|
90
150
|
|
|
91
|
-
if (
|
|
151
|
+
if (track?.policy === "BLOCK" || track?.state === "blocked") {
|
|
152
|
+
throw new Error("Track bị chặn (policy/geo).");
|
|
153
|
+
}
|
|
154
|
+
if (track?.has_downloads === false && track?.streamable === false) {
|
|
155
|
+
throw new Error("Track không cho phép stream.");
|
|
156
|
+
}
|
|
92
157
|
|
|
93
|
-
const
|
|
94
|
-
|
|
158
|
+
const transcoding = this._pickBestTranscoding(track);
|
|
159
|
+
if (!transcoding) throw new Error("Không tìm thấy stream phù hợp.");
|
|
160
|
+
|
|
161
|
+
const streamUrl = await this.getStreamUrl(transcoding.url);
|
|
162
|
+
if (transcoding.format?.protocol === "hls") {
|
|
163
|
+
return m3u8stream(streamUrl, {
|
|
164
|
+
requestOptions: {
|
|
165
|
+
headers: { "User-Agent": this.http.defaults.headers["User-Agent"] },
|
|
166
|
+
},
|
|
167
|
+
...options,
|
|
168
|
+
});
|
|
169
|
+
} else {
|
|
170
|
+
const res = await this.http.get(streamUrl, { responseType: "stream" });
|
|
171
|
+
return res.data;
|
|
172
|
+
}
|
|
95
173
|
} catch (e) {
|
|
96
|
-
console.error("Failed to download track");
|
|
174
|
+
console.error("Failed to download track:", e?.message || e);
|
|
97
175
|
return null;
|
|
98
176
|
}
|
|
99
177
|
}
|
|
100
178
|
|
|
101
|
-
// Fetch single item (track/playlist/user)
|
|
102
179
|
async fetchItem(itemUrl) {
|
|
103
|
-
|
|
180
|
+
await this.ensureReady();
|
|
181
|
+
const url = `${this.apiBaseUrl}/resolve?url=${encodeURIComponent(itemUrl)}&client_id=${this.clientId}`;
|
|
104
182
|
try {
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
} catch (error) {
|
|
183
|
+
return await this._getJson(url);
|
|
184
|
+
} catch (e) {
|
|
108
185
|
throw new Error("Failed to fetch item details");
|
|
109
186
|
}
|
|
110
187
|
}
|
|
111
188
|
|
|
112
|
-
// Fetch multiple tracks by their IDs
|
|
113
189
|
async fetchTracksByIds(trackIds) {
|
|
114
|
-
|
|
115
|
-
const
|
|
190
|
+
await this.ensureReady();
|
|
191
|
+
const ids = Array.from(new Set(trackIds.filter(Boolean)));
|
|
192
|
+
if (!ids.length) return [];
|
|
193
|
+
const chunkSize = 50;
|
|
194
|
+
const chunks = [];
|
|
195
|
+
for (let i = 0; i < ids.length; i += chunkSize) chunks.push(ids.slice(i, i + chunkSize));
|
|
196
|
+
|
|
116
197
|
try {
|
|
117
|
-
const
|
|
118
|
-
|
|
198
|
+
const results = await Promise.all(
|
|
199
|
+
chunks.map(async (chunk) => {
|
|
200
|
+
const url = `${this.apiBaseUrl}/tracks?ids=${chunk.join(",")}&client_id=${this.clientId}`;
|
|
201
|
+
return await this._getJson(url);
|
|
202
|
+
}),
|
|
203
|
+
);
|
|
204
|
+
return results.flat();
|
|
119
205
|
} 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
|
+
});
|
|
120
211
|
throw new Error("Failed to fetch tracks by IDs");
|
|
121
212
|
}
|
|
122
213
|
}
|
|
123
214
|
|
|
124
|
-
// Get HLS stream URL
|
|
125
215
|
async getStreamUrl(transcodingUrl) {
|
|
126
|
-
|
|
216
|
+
await this.ensureReady();
|
|
217
|
+
const url = `${transcodingUrl}${transcodingUrl.includes("?") ? "&" : "?"}client_id=${this.clientId}`;
|
|
127
218
|
try {
|
|
128
|
-
const
|
|
219
|
+
const data = await this._getJson(url);
|
|
220
|
+
if (!data?.url) throw new Error("No stream URL in response");
|
|
129
221
|
return data.url;
|
|
130
222
|
} catch (error) {
|
|
223
|
+
if (error?.response?.status === 401 || error?.response?.status === 403) {
|
|
224
|
+
this.clientId = null;
|
|
225
|
+
this._initPromise = this.init();
|
|
226
|
+
await this._initPromise;
|
|
227
|
+
const retryUrl = `${transcodingUrl}${transcodingUrl.includes("?") ? "&" : "?"}client_id=${this.clientId}`;
|
|
228
|
+
const data = await this._getJson(retryUrl);
|
|
229
|
+
if (!data?.url) throw new Error("No stream URL in response (after refresh)");
|
|
230
|
+
return data.url;
|
|
231
|
+
}
|
|
131
232
|
throw new Error("Failed to fetch stream URL");
|
|
132
233
|
}
|
|
133
234
|
}
|
|
235
|
+
|
|
236
|
+
/** pick transcoding: HLS (opus > mp3), fallback progressive mp3 */
|
|
237
|
+
_pickBestTranscoding(track) {
|
|
238
|
+
const list = Array.isArray(track?.media?.transcodings) ? track.media.transcodings : [];
|
|
239
|
+
if (!list.length) return null;
|
|
240
|
+
|
|
241
|
+
const score = (t) => {
|
|
242
|
+
const proto = t?.format?.protocol;
|
|
243
|
+
const mime = t?.format?.mime_type || "";
|
|
244
|
+
if (proto === "hls" && mime.includes("opus")) return 100;
|
|
245
|
+
if (proto === "hls" && mime.includes("mpeg")) return 90;
|
|
246
|
+
if (proto === "progressive" && mime.includes("mpeg")) return 70;
|
|
247
|
+
return 10;
|
|
248
|
+
};
|
|
249
|
+
|
|
250
|
+
return [...list].sort((a, b) => score(b) - score(a))[0];
|
|
251
|
+
}
|
|
252
|
+
async _resolveTrackId(input) {
|
|
253
|
+
await this.ensureReady();
|
|
254
|
+
if (!input) throw new Error("Missing track identifier");
|
|
255
|
+
if (typeof input === "number" || /^[0-9]+$/.test(String(input))) {
|
|
256
|
+
return Number(input);
|
|
257
|
+
}
|
|
258
|
+
const item = await this.fetchItem(input);
|
|
259
|
+
if (item?.kind !== "track" || !item?.id) {
|
|
260
|
+
throw new Error("Cannot resolve track ID from input");
|
|
261
|
+
}
|
|
262
|
+
return item.id;
|
|
263
|
+
}
|
|
264
|
+
|
|
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
|
+
async getRelatedTracks(track, { limit = 20, offset = 0 } = {}) {
|
|
274
|
+
await this.ensureReady();
|
|
275
|
+
const id = await this._resolveTrackId(track);
|
|
276
|
+
|
|
277
|
+
const url = `${this.apiBaseUrl}/tracks/${id}/related` + `?limit=${limit}&offset=${offset}&client_id=${this.clientId}`;
|
|
278
|
+
|
|
279
|
+
try {
|
|
280
|
+
const data = await this._getJson(url);
|
|
281
|
+
const collection = Array.isArray(data?.collection) ? data.collection : Array.isArray(data) ? data : [];
|
|
282
|
+
return collection.filter((t) => t?.permalink_url && t?.title && t?.duration);
|
|
283
|
+
} catch (e) {
|
|
284
|
+
return [];
|
|
285
|
+
}
|
|
286
|
+
}
|
|
134
287
|
}
|
|
135
288
|
|
|
136
289
|
module.exports = SoundCloud;
|